Merge branch 'custom_grain' into staging_environment

This commit is contained in:
csawatzky 2026-01-06 13:24:04 -06:00
commit 8ec08b0c36
32 changed files with 1366 additions and 257 deletions

2
package-lock.json generated
View file

@ -10953,7 +10953,7 @@
}, },
"node_modules/protobuf-ts": { "node_modules/protobuf-ts": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#3f647071f211c4552c2acc23a8ecabb6904a4156", "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#75f64f81d9b4ba7639d9978af37c361fca34c279",
"dependencies": { "dependencies": {
"protobufjs": "^6.8.8" "protobufjs": "^6.8.8"
} }

View file

@ -12,6 +12,7 @@ import { makeStyles } from '@mui/styles'
import { CssBaseline, Theme } from '@mui/material' import { CssBaseline, Theme } from '@mui/material'
import { AppThemeProvider } from 'theme/AppThemeProvider' import { AppThemeProvider } from 'theme/AppThemeProvider'
import HTTPProvider from 'providers/http' import HTTPProvider from 'providers/http'
import { useSnackbar } from 'hooks'
// import FirmwareLoader from './FirmwareLoader' // import FirmwareLoader from './FirmwareLoader'
const reducer = (state: GlobalState, action: GlobalStateAction): GlobalState => { const reducer = (state: GlobalState, action: GlobalStateAction): GlobalState => {
@ -64,6 +65,7 @@ export default function UserWrapper(props: Props) {
const useAuth = useAuth0(); const useAuth = useAuth0();
const hasFetched = useRef(false); const hasFetched = useRef(false);
const [global, setGlobal] = useState<undefined | GlobalState>(undefined) const [global, setGlobal] = useState<undefined | GlobalState>(undefined)
const snackbar = useSnackbar()
const user_id = or(useAuth.user?.sub, "") const user_id = or(useAuth.user?.sub, "")
@ -81,7 +83,8 @@ export default function UserWrapper(props: Props) {
backgroundTasksComplete: false, backgroundTasksComplete: false,
firmware: new Map() firmware: new Map()
}) })
}).catch(() => { }).catch((err) => {
snackbar.error("get user and team: "+err)
userAPI.getUser(user_id).then(user => { userAPI.getUser(user_id).then(user => {
setGlobal({ setGlobal({
user: user ? user : User.create(), user: user ? user : User.create(),
@ -92,7 +95,8 @@ export default function UserWrapper(props: Props) {
backgroundTasksComplete: false, backgroundTasksComplete: false,
firmware: new Map() firmware: new Map()
}) })
}).catch(() => { }).catch((err) => {
snackbar.error("get user: "+err)
setGlobal(globalDefault) setGlobal(globalDefault)
}) })

View file

@ -200,7 +200,8 @@ export default function BinCard(props: Props) {
const typeDisplay = () => { const typeDisplay = () => {
let grainType = bin.settings.inventory?.grainType ?? pond.Grain.GRAIN_NONE; let grainType = bin.settings.inventory?.grainType ?? pond.Grain.GRAIN_NONE;
if (bin.storage() === pond.BinStorage.BIN_STORAGE_SUPPORTED_GRAIN) { switch (bin.storage()){
case pond.BinStorage.BIN_STORAGE_SUPPORTED_GRAIN:
return ( return (
<Box> <Box>
<Typography variant="subtitle2" style={{ fontSize: "0.7rem", fontWeight: 700 }}> <Typography variant="subtitle2" style={{ fontSize: "0.7rem", fontWeight: 700 }}>
@ -210,15 +211,16 @@ export default function BinCard(props: Props) {
{bin.settings.inventory?.grainSubtype} {bin.settings.inventory?.grainSubtype}
</Typography> </Typography>
</Box> </Box>
); )
} default:
return ( return (
<Box> <Box>
<Typography variant="subtitle2" style={{ fontSize: "0.7rem", fontWeight: 700 }}> <Typography variant="subtitle2" style={{ fontSize: "0.7rem", fontWeight: 700 }}>
{bin.settings.inventory?.customTypeName} {bin.grainName()}
</Typography> </Typography>
</Box> </Box>
); );
}
}; };
const tempDisplay = () => { const tempDisplay = () => {
@ -417,6 +419,7 @@ export default function BinCard(props: Props) {
hottestNodeTemp={valDisplay === "high" ? hotNode?.temp : undefined} hottestNodeTemp={valDisplay === "high" ? hotNode?.temp : undefined}
coldestNodeTemp={valDisplay === "low" ? coldNode?.temp : undefined} coldestNodeTemp={valDisplay === "low" ? coldNode?.temp : undefined}
inventoryControl={bin.inventoryControl()} inventoryControl={bin.inventoryControl()}
customGrain={bin.customGrain()}
/> />
</Box> </Box>
</Box> </Box>

View file

@ -39,11 +39,12 @@ interface Props {
heaters?: Controller[]; heaters?: Controller[];
fans?: Controller[]; fans?: Controller[];
grain?: pond.Grain; grain?: pond.Grain;
customGrain?: pond.GrainSettings
} }
export default function BinConditioningCard(props: Props) { export default function BinConditioningCard(props: Props) {
const classes = useStyles(); const classes = useStyles();
const { interactions, interactionDevices, plenums, ambients, heaters, fans, grain, mode } = props; const { interactions, interactionDevices, plenums, ambients, heaters, fans, grain, mode, customGrain } = props;
const [sourceMap, setSourceMap] = useState<Map<string, Component>>(new Map()); const [sourceMap, setSourceMap] = useState<Map<string, Component>>(new Map());
const [sinkMap, setSinkMap] = useState<Map<string, Component>>(new Map()); const [sinkMap, setSinkMap] = useState<Map<string, Component>>(new Map());
@ -87,6 +88,7 @@ export default function BinConditioningCard(props: Props) {
sink={sink} sink={sink}
source={source} source={source}
grain={grain} grain={grain}
customGrain={customGrain}
/> />
); );
} }

View file

@ -84,10 +84,11 @@ interface Props {
source: Component; source: Component;
sink: Component; sink: Component;
grain?: pond.Grain; grain?: pond.Grain;
customGrain?: pond.GrainSettings
} }
export default function BinConditioningInteraction(props: Props) { export default function BinConditioningInteraction(props: Props) {
const { interaction, source, sink, grain, deviceId } = props; const { interaction, source, sink, grain, deviceId, customGrain } = props;
const [sliderVals, setSliderVals] = useState<Map<quack.MeasurementType, number>>(new Map()); const [sliderVals, setSliderVals] = useState<Map<quack.MeasurementType, number>>(new Map());
const [sliderMarks, setSliderMarks] = useState<Map<quack.MeasurementType, number>>(new Map()); const [sliderMarks, setSliderMarks] = useState<Map<quack.MeasurementType, number>>(new Map());
//this is the emc value calculated from the interactions temp and humidity conditions //this is the emc value calculated from the interactions temp and humidity conditions
@ -127,7 +128,6 @@ export default function BinConditioningInteraction(props: Props) {
if ( if (
grain !== undefined && grain !== undefined &&
grain !== pond.Grain.GRAIN_INVALID && grain !== pond.Grain.GRAIN_INVALID &&
grain !== pond.Grain.GRAIN_CUSTOM &&
temp && temp &&
hum hum
) { ) {
@ -135,8 +135,9 @@ export default function BinConditioningInteraction(props: Props) {
//the emc calc needs the temp to be in celsius //the emc calc needs the temp to be in celsius
temp = fahrenheitToCelsius(temp); temp = fahrenheitToCelsius(temp);
} }
let emc = ExtractMoisture(grain, temp, hum); let emc = ExtractMoisture(grain, temp, hum, customGrain)
setBaseEMC(emc); setBaseEMC(emc === hum ? undefined : emc);
} }
}, [sliderVals, grain]); }, [sliderVals, grain]);

View file

@ -147,6 +147,7 @@ interface Props {
hottestNodeTemp?: number; hottestNodeTemp?: number;
coldestNodeTemp?: number; coldestNodeTemp?: number;
inventoryControl?: pond.BinInventoryControl; inventoryControl?: pond.BinInventoryControl;
customGrain?: pond.GrainSettings;
} }
interface GrainNodePoint { interface GrainNodePoint {
@ -211,7 +212,8 @@ export default function BinSVGV2(props: Props) {
hottestNodeTemp, hottestNodeTemp,
coldestNodeTemp, coldestNodeTemp,
inventoryControl, inventoryControl,
co2Sensors co2Sensors,
customGrain
} = props; } = props;
const [{ user }] = useGlobalState(); const [{ user }] = useGlobalState();
const [extraDetails, setExtraDetails] = useState<"temps" | "hums">("temps"); const [extraDetails, setExtraDetails] = useState<"temps" | "hums">("temps");
@ -683,7 +685,7 @@ export default function BinSVGV2(props: Props) {
x: cablePos, x: cablePos,
y: nodeY + 10, y: nodeY + 10,
stroke: "green", stroke: "green",
value: ExtractMoisture(props.grainType, temp, cable.humidities[index] ?? 0).toFixed(1)+"%", value: ExtractMoisture(props.grainType, temp, cable.humidities[index] ?? 0, customGrain).toFixed(1)+"%",
}) })
!showTempHum && !showTempHum &&
nodeClickData.push({ nodeClickData.push({

View file

@ -65,6 +65,7 @@ import { getDistanceUnit, or, getTemperatureUnit, getGrainUnit } from "utils";
import { makeStyles } from "@mui/styles"; import { makeStyles } from "@mui/styles";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import BinSelector from "./BinSelector"; import BinSelector from "./BinSelector";
import CustomGrainSelector from "grain/CustomGrainSelector";
// import BinSelector from "./BinSelector"; // import BinSelector from "./BinSelector";
const useStyles = makeStyles((theme: Theme) => { const useStyles = makeStyles((theme: Theme) => {
@ -205,6 +206,7 @@ export default function BinSettings(props: Props) {
const libracartAPI = useLibraCartProxyAPI() const libracartAPI = useLibraCartProxyAPI()
const [lcDestination, setlcDestination] = useState<Option | null>() const [lcDestination, setlcDestination] = useState<Option | null>()
const [lcDestinationOptions, setlcDestinationOptions] = useState<Option[]>([]) const [lcDestinationOptions, setlcDestinationOptions] = useState<Option[]>([])
const [customGrain, setCustomGrain] = useState<pond.GrainSettings>()
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@ -236,6 +238,9 @@ export default function BinSettings(props: Props) {
initForm.inventory.grainType initForm.inventory.grainType
).bushelsPerTonne; ).bushelsPerTonne;
} }
if (initForm.inventory.customGrain){
setCustomGrain(initForm.inventory.customGrain)
}
//if the target temp is not set (older bins) make it the midpoint of the high and low temps assuming they are set otherwise make it 15 //if the target temp is not set (older bins) make it the midpoint of the high and low temps assuming they are set otherwise make it 15
if (!initForm.inventory.targetTemperature || initForm.inventory.targetTemperature) { if (!initForm.inventory.targetTemperature || initForm.inventory.targetTemperature) {
if (initForm.highTemp && initForm.lowTemp) { if (initForm.highTemp && initForm.lowTemp) {
@ -444,6 +449,11 @@ export default function BinSettings(props: Props) {
form.inventory.inventoryControl = inventoryControl form.inventory.inventoryControl = inventoryControl
form.inventory.autoThreshold = autoFillThreshold form.inventory.autoThreshold = autoFillThreshold
form.inventory.lidarDropCm = getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? lidarDropDistance * 30.48 : lidarDropDistance form.inventory.lidarDropCm = getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? lidarDropDistance * 30.48 : lidarDropDistance
if(form.inventory.grainType !== pond.Grain.GRAIN_CUSTOM){
form.inventory.customGrain = undefined
}else{
form.inventory.customGrain = customGrain
}
} }
binAPI binAPI
@ -493,6 +503,11 @@ export default function BinSettings(props: Props) {
form.inventory.autoThreshold = autoFillThreshold form.inventory.autoThreshold = autoFillThreshold
//if the users preferences are in feet convert the distance to cm otherwise it was entered as cm so use that //if the users preferences are in feet convert the distance to cm otherwise it was entered as cm so use that
form.inventory.lidarDropCm = getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? lidarDropDistance * 30.48 : lidarDropDistance form.inventory.lidarDropCm = getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? lidarDropDistance * 30.48 : lidarDropDistance
if(form.inventory.grainType !== pond.Grain.GRAIN_CUSTOM){
form.inventory.customGrain = undefined
}else{
form.inventory.customGrain = customGrain
}
} }
binAPI binAPI
.updateBin(bin.key(), form, as) .updateBin(bin.key(), form, as)
@ -1014,7 +1029,37 @@ export default function BinSettings(props: Props) {
{isCustom ? "Custom" : "Grain"} {isCustom ? "Custom" : "Grain"}
</Typography> */} </Typography> */}
<Box> <Box>
{!isCustomInventory && ( {isCustomInventory ?
<React.Fragment>
{storageType === pond.BinStorage.BIN_STORAGE_FERTILIZER ?
<React.Fragment>
<TextField
label="Type"
value={customTypeName}
type="text"
onChange={event => {
updateForm(
"inventory",
pond.BinInventory.create({
...form.inventory,
customTypeName: event.target.value
})
);
updateFormExtension("customTypeName", event.target.value);
}}
fullWidth
variant="outlined"
className={classes.bottomSpacing}
/>
</React.Fragment>
:
<Box sx={{border: "1px solid white", borderRadius: 2, padding: 2, marginBottom: 2}}>
<CustomGrainSelector initialGrain={customGrain} onGrainSettingsChange={(newGrainSettings) => {setCustomGrain(newGrainSettings)}}/>
</Box>
}
</React.Fragment>
:
<React.Fragment>
<Box className={classes.bottomSpacing}> <Box className={classes.bottomSpacing}>
<SearchSelect <SearchSelect
label="Type" label="Type"
@ -1040,49 +1085,6 @@ export default function BinSettings(props: Props) {
options={grainOptions} options={grainOptions}
/> />
</Box> </Box>
)}
{isCustomInventory ? (
<React.Fragment>
<TextField
label="Type"
value={customTypeName}
type="text"
onChange={event => {
updateForm(
"inventory",
pond.BinInventory.create({
...form.inventory,
customTypeName: event.target.value
})
);
updateFormExtension("customTypeName", event.target.value);
}}
fullWidth
variant="outlined"
className={classes.bottomSpacing}
/>
{storageType !== pond.BinStorage.BIN_STORAGE_FERTILIZER && (
<TextField
label="Bushels Per Tonne"
value={bushPerTonne}
type="number"
onChange={event => {
updateForm(
"inventory",
pond.BinInventory.create({
...form.inventory,
bushelsPerTonne: +event.target.value
})
);
updateFormExtension("bushelsPerTonne", event.target.value);
}}
fullWidth
variant="outlined"
className={classes.bottomSpacing}
/>
)}
</React.Fragment>
) : (
<TextField <TextField
label="Grain Variant" label="Grain Variant"
value={grainSubtype} value={grainSubtype}
@ -1102,7 +1104,8 @@ export default function BinSettings(props: Props) {
disabled={!grainType} disabled={!grainType}
className={classes.bottomSpacing} className={classes.bottomSpacing}
/> />
)} </React.Fragment>
}
{getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_BUSHELS || {getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_BUSHELS ||
storageType === pond.BinStorage.BIN_STORAGE_FERTILIZER || storageType === pond.BinStorage.BIN_STORAGE_FERTILIZER ||
formExtension.bushelsPerTonne === "0" || formExtension.bushelsPerTonne === "0" ||

View file

@ -70,6 +70,7 @@ import Edit from "@mui/icons-material/Edit";
import { makeStyles } from "@mui/styles"; import { makeStyles } from "@mui/styles";
import ButtonGroup from "common/ButtonGroup"; import ButtonGroup from "common/ButtonGroup";
import ModeChangeDialog from "./conditioning/modeChangeDialog"; import ModeChangeDialog from "./conditioning/modeChangeDialog";
import CustomGrainSelector from "grain/CustomGrainSelector";
const useStyles = makeStyles((theme: Theme) => { const useStyles = makeStyles((theme: Theme) => {
return ({ return ({
@ -258,7 +259,7 @@ export default function BinVisualizer(props: Props) {
//const [highTempCable, setHighTempCable] = useState<GrainCable>(); //const [highTempCable, setHighTempCable] = useState<GrainCable>();
//const [lowTempCable, setLowTempCable] = useState<GrainCable>(); //const [lowTempCable, setLowTempCable] = useState<GrainCable>();
//the switch value to determine what to display in the grain condition box //the switch value to determine what to display in the grain condition box
const [valueDisplay, setValueDisplay] = useState<"low" | "avg" | "high">("avg"); const [valueDisplay, setValueDisplay] = useState<"low" | "avg" | "high">("high");
//the variables to be able to change the grain type through a unique dialog outside of the settings //the variables to be able to change the grain type through a unique dialog outside of the settings
const [grainChangeDialog, setGrainChangeDialog] = useState(false); const [grainChangeDialog, setGrainChangeDialog] = useState(false);
const grainOptions = GrainOptions(); const grainOptions = GrainOptions();
@ -286,6 +287,7 @@ export default function BinVisualizer(props: Props) {
const [openModeChange, setOpenModeChange] = useState(false) const [openModeChange, setOpenModeChange] = useState(false)
const [modeChangeInProgress, setModeChangeInProgress] = useState(false) const [modeChangeInProgress, setModeChangeInProgress] = useState(false)
const [newGrainSettings, setNewGrainSettings] = useState<pond.GrainSettings>()
useEffect(() => { useEffect(() => {
setModeTime(moment(bin.status.lastModeChange)); setModeTime(moment(bin.status.lastModeChange));
@ -327,6 +329,7 @@ export default function BinVisualizer(props: Props) {
setBushPerTonne(bin.settings.inventory.bushelsPerTonne.toFixed(2)); setBushPerTonne(bin.settings.inventory.bushelsPerTonne.toFixed(2));
setGrainSubtype(bin.subtype()); setGrainSubtype(bin.subtype());
setNewBinMode(bin.settings.mode); setNewBinMode(bin.settings.mode);
setNewGrainSettings(bin.customGrain());
} }
if (bin.settings) { if (bin.settings) {
let t = bin.settings.outdoorTemp; let t = bin.settings.outdoorTemp;
@ -515,18 +518,13 @@ export default function BinVisualizer(props: Props) {
const capacity = bin.settings.specs?.bushelCapacity ?? 0; const capacity = bin.settings.specs?.bushelCapacity ?? 0;
const grainBushels = bin.bushels(); const grainBushels = bin.bushels();
const isEmpty = bin.settings.inventory?.empty === true || !grainBushels || grainBushels <= 0; const isEmpty = bin.settings.inventory?.empty === true || !grainBushels || grainBushels <= 0;
const grainType = bin.settings.inventory?.grainType;
const grainTypeName = isEmpty || !grainType ? "" : GrainDescriber(grainType).name;
const customTypeName = bin.settings.inventory?.customTypeName;
const grainSubtype = bin.settings.inventory?.grainSubtype; const grainSubtype = bin.settings.inventory?.grainSubtype;
return ( return (
<Box> <Box>
{/* grain display */} {/* grain display */}
<Box display="flex" justifyContent="space-between"> <Box display="flex" justifyContent="space-between">
<Typography variant="subtitle2" color="textPrimary" style={{ fontWeight: 800 }}> <Typography variant="subtitle2" color="textPrimary" style={{ fontWeight: 800 }}>
{bin.storage() === pond.BinStorage.BIN_STORAGE_SUPPORTED_GRAIN {isEmpty ? "" : bin.grainName()}
? grainTypeName
: customTypeName}
{grainSubtype !== "" ? " - " + grainSubtype : ""} {grainSubtype !== "" ? " - " + grainSubtype : ""}
</Typography> </Typography>
<Box <Box
@ -768,6 +766,7 @@ export default function BinVisualizer(props: Props) {
setGrainType(bin.settings.inventory.grainType); setGrainType(bin.settings.inventory.grainType);
setGrainOption(ToGrainOption(bin.settings.inventory.grainType)); setGrainOption(ToGrainOption(bin.settings.inventory.grainType));
setBushPerTonne(bin.settings.inventory.bushelsPerTonne.toFixed(2)); setBushPerTonne(bin.settings.inventory.bushelsPerTonne.toFixed(2));
setNewGrainSettings(bin.customGrain())
setGrainSubtype(bin.subtype()); setGrainSubtype(bin.subtype());
} }
setGrainChangeDialog(false); setGrainChangeDialog(false);
@ -799,11 +798,13 @@ export default function BinVisualizer(props: Props) {
setCustomTypeName(""); setCustomTypeName("");
setGrainOption(ToGrainOption(pond.Grain.GRAIN_NONE)); setGrainOption(ToGrainOption(pond.Grain.GRAIN_NONE));
if (checked) { if (checked) {
setStorageType(pond.BinStorage.BIN_STORAGE_SUPPORTED_GRAIN);
setGrainType(pond.Grain.GRAIN_CUSTOM);
} else {
setStorageType(pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN); setStorageType(pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN);
setGrainType(pond.Grain.GRAIN_CUSTOM);
setNewGrainSettings(bin.customGrain())
} else {
setStorageType(pond.BinStorage.BIN_STORAGE_SUPPORTED_GRAIN);
setGrainType(pond.Grain.GRAIN_NONE); setGrainType(pond.Grain.GRAIN_NONE);
setNewGrainSettings(undefined)
} }
}} }}
name="storage" name="storage"
@ -834,7 +835,7 @@ export default function BinVisualizer(props: Props) {
)} )}
{isCustomInventory ? ( {isCustomInventory ? (
<React.Fragment> <React.Fragment>
<TextField {/* <TextField
label="Type" label="Type"
value={customTypeName} value={customTypeName}
type="text" type="text"
@ -859,7 +860,8 @@ export default function BinVisualizer(props: Props) {
variant="outlined" variant="outlined"
className={classes.bottomSpacing} className={classes.bottomSpacing}
/> />
)} )} */}
<CustomGrainSelector initialGrain={bin.customGrain()} onGrainSettingsChange={settings => {setNewGrainSettings(settings)}}/>
</React.Fragment> </React.Fragment>
) : ( ) : (
<TextField <TextField
@ -1404,6 +1406,7 @@ export default function BinVisualizer(props: Props) {
} }
}} }}
inventoryControl={bin.inventoryControl()} inventoryControl={bin.inventoryControl()}
customGrain={bin.customGrain()}
/> />
</Box> </Box>
</Box> </Box>
@ -1574,6 +1577,12 @@ export default function BinVisualizer(props: Props) {
const fanPerformance = () => { const fanPerformance = () => {
let totalCFM = bin.status.fanCfm; let totalCFM = bin.status.fanCfm;
let bushelCFM = bin.status.cfmPerBushel; let bushelCFM = bin.status.cfmPerBushel;
let emc = ExtractMoisture(
bin.grain(),
activePlenum?.tempHumidity?.temperature ?? 0,
activePlenum?.tempHumidity?.humidity ?? 0,
bin.customGrain()
)
return ( return (
<Box> <Box>
<Typography style={{ fontSize: isMobile ? "0.85rem" : "1.0rem", fontWeight: 650 }}> <Typography style={{ fontSize: isMobile ? "0.85rem" : "1.0rem", fontWeight: 650 }}>
@ -1616,20 +1625,18 @@ export default function BinVisualizer(props: Props) {
{cfmDryWarning(bushelCFM)} {cfmDryWarning(bushelCFM)}
</Box> </Box>
</Grid> </Grid>
<Grid > <Grid>
{emc !== activePlenum?.tempHumidity?.humidity &&
<Box className={classes.lightBox}> <Box className={classes.lightBox}>
<Typography align="center" style={{ fontWeight: 650, color: emcColour }}> <Typography align="center" style={{ fontWeight: 650, color: emcColour }}>
{ExtractMoisture( {emc.toFixed(2)}
bin.grain(),
activePlenum?.tempHumidity?.temperature ?? 0,
activePlenum?.tempHumidity?.humidity ?? 0
).toFixed(2)}
% %
</Typography> </Typography>
<Typography align="center" style={{ fontWeight: 650 }}> <Typography align="center" style={{ fontWeight: 650 }}>
Plenum EMC Plenum EMC
</Typography> </Typography>
</Box> </Box>
}
</Grid> </Grid>
</Grid> </Grid>
</Box> </Box>
@ -1638,6 +1645,12 @@ export default function BinVisualizer(props: Props) {
}; };
const ambientDisplay = () => { const ambientDisplay = () => {
let emc = ExtractMoisture(
bin.grain(),
ambient?.temperature ?? 0,
ambient?.humidity ?? 0,
bin.customGrain()
)
return ( return (
<Box> <Box>
<Typography style={{ fontSize: isMobile ? "0.85rem" : "1.0rem", fontWeight: 650 }}> <Typography style={{ fontSize: isMobile ? "0.85rem" : "1.0rem", fontWeight: 650 }}>
@ -1677,19 +1690,17 @@ export default function BinVisualizer(props: Props) {
</Box> </Box>
</Grid> </Grid>
<Grid > <Grid >
{emc !== ambient?.humidity &&
<Box className={classes.lightBox}> <Box className={classes.lightBox}>
<Typography align="center" style={{ fontWeight: 650, color: emcColour }}> <Typography align="center" style={{ fontWeight: 650, color: emcColour }}>
{ExtractMoisture( {emc.toFixed(2)}
bin.grain(),
ambient?.temperature ?? 0,
ambient?.humidity ?? 0
).toFixed(2)}
% %
</Typography> </Typography>
<Typography align="center" style={{ fontWeight: 650 }}> <Typography align="center" style={{ fontWeight: 650 }}>
Ambient EMC Ambient EMC
</Typography> </Typography>
</Box> </Box>
}
</Grid> </Grid>
</Grid> </Grid>
</Box> </Box>
@ -1968,6 +1979,7 @@ export default function BinVisualizer(props: Props) {
? 0 ? 0
: parseFloat(bushPerTonne); : parseFloat(bushPerTonne);
b.settings.inventory.grainSubtype = grainSubtype; b.settings.inventory.grainSubtype = grainSubtype;
b.settings.inventory.customGrain = newGrainSettings
} }
if (b.settings.outdoorTemp !== undefined) { if (b.settings.outdoorTemp !== undefined) {

View file

@ -170,18 +170,18 @@ export default function BinGraphs(props: Props) {
setEndDate(newEndDate); setEndDate(newEndDate);
}; };
const determineGrainColour = () => { // const determineGrainColour = () => {
let col = "yellow"; // let col = "yellow";
let binInv = bin.settings.inventory; // let binInv = bin.settings.inventory;
if (binInv) { // if (binInv) {
if (binInv.grainType === pond.Grain.GRAIN_CUSTOM) { // if (binInv.grainType === pond.Grain.GRAIN_CUSTOM) {
col = stringToMaterialColour(binInv.customTypeName); // col = stringToMaterialColour(binInv.customTypeName);
} else if (binInv.grainType !== pond.Grain.GRAIN_NONE) { // } else if (binInv.grainType !== pond.Grain.GRAIN_NONE) {
col = GrainDescriber(binInv.grainType).colour; // col = GrainDescriber(binInv.grainType).colour;
} // }
} // }
return col; // return col;
}; // };
const inventoryGraph = () => { const inventoryGraph = () => {
return ( return (
@ -193,7 +193,7 @@ export default function BinGraphs(props: Props) {
endDate={endDate} endDate={endDate}
binLoading={binLoading} binLoading={binLoading}
bin={bin} bin={bin}
colour={determineGrainColour()} colour={bin.grainColour()}
fertilizerBin={bin.settings.storage === pond.BinStorage.BIN_STORAGE_FERTILIZER} fertilizerBin={bin.settings.storage === pond.BinStorage.BIN_STORAGE_FERTILIZER}
/> />
</Grid> </Grid>

View file

@ -92,7 +92,8 @@ export default function BinGraphsTrending(props: Props) {
trend: ExtractMoisture( trend: ExtractMoisture(
bin.grain(), bin.grain(),
val.values[0], val.values[0],
plenumHumidity.values[i].values[0] plenumHumidity.values[i].values[0],
bin.customGrain()
) )
}; };
trendData.push(trendPoint); trendData.push(trendPoint);

View file

@ -4,6 +4,7 @@ import {
AccordionSummary, AccordionSummary,
Alert, Alert,
AlertTitle, AlertTitle,
Box,
Button, Button,
Collapse, Collapse,
FormControl, FormControl,
@ -47,6 +48,7 @@ import { getDistanceUnit } from "utils";
import { GrainOptions } from "grain"; import { GrainOptions } from "grain";
import CompModes from "component/ComponentMode.json"; import CompModes from "component/ComponentMode.json";
import FanPicker from "fans/fanPicker"; import FanPicker from "fans/fanPicker";
import CustomGrainSelector from "grain/CustomGrainSelector";
const useStyles = makeStyles((theme: Theme) => { const useStyles = makeStyles((theme: Theme) => {
return ({ return ({
@ -136,6 +138,7 @@ export default function ComponentForm(props: Props) {
sensorDistance: "0", sensorDistance: "0",
}); });
const [compMode, setCompMode] = useState<any>(); const [compMode, setCompMode] = useState<any>();
const [useCustomGrain, setUseCustomGrain] = useState<boolean>(false)
//const [numCalibrations, setNumCalibrations] = useState(0) //const [numCalibrations, setNumCalibrations] = useState(0)
useEffect(() => { useEffect(() => {
@ -192,6 +195,10 @@ export default function ComponentForm(props: Props) {
sensorDistance = sensorDistance / 30.48; sensorDistance = sensorDistance / 30.48;
} }
if(formComponent.settings.customGrain){
setUseCustomGrain(true)
}
setForm({ setForm({
component: formComponent, component: formComponent,
measure: formComponent.settings.measurementPeriodMs > 0, measure: formComponent.settings.measurementPeriodMs > 0,
@ -383,6 +390,13 @@ export default function ComponentForm(props: Props) {
setForm(f); setForm(f);
}; };
const updateCustomGrain = (grainSettings?: pond.GrainSettings) => {
let f = cloneDeep(form)
f.component.settings.customGrain = grainSettings
f.component.settings.grainType = pond.Grain.GRAIN_CUSTOM
setForm(f)
}
const updateGrainType = (option: Option | null) => { const updateGrainType = (option: Option | null) => {
let f = cloneDeep(form); let f = cloneDeep(form);
@ -622,6 +636,27 @@ export default function ComponentForm(props: Props) {
const grainSelect = () => { const grainSelect = () => {
let selected = findSelectedGrain(grainOptions); let selected = findSelectedGrain(grainOptions);
return ( return (
<React.Fragment>
<FormControlLabel
control={
<Switch
value={useCustomGrain}
checked={useCustomGrain}
title="Custom Grain"
onClick={() => {
setUseCustomGrain(!useCustomGrain);
updateCustomGrain()
}}
/>
}
label="Custom Grain"
labelPlacement="start"
/>
{useCustomGrain ?
<Box sx={{border: "1px solid white", borderRadius: 2, padding: 2, marginBottom: 2}}>
<CustomGrainSelector onGrainSettingsChange={updateCustomGrain} initialGrain={form.component.customGrainProps()}/>
</Box>
:
<SearchSelect <SearchSelect
selected={selected} selected={selected}
changeSelection={updateGrainType} changeSelection={updateGrainType}
@ -629,6 +664,8 @@ export default function ComponentForm(props: Props) {
options={grainOptions} options={grainOptions}
group group
/> />
}
</React.Fragment>
); );
}; };

View file

@ -10,6 +10,7 @@ import { Pressure } from "models/Pressure";
import { GrainCable } from "models/GrainCable"; import { GrainCable } from "models/GrainCable";
import { extension, Summary } from "pbHelpers/ComponentType"; import { extension, Summary } from "pbHelpers/ComponentType";
import { makeStyles } from "@mui/styles"; import { makeStyles } from "@mui/styles";
import { stringToMaterialColour } from "utils";
interface Props { interface Props {
component: Component | Plenum | Pressure | GrainCable; component: Component | Plenum | Pressure | GrainCable;
@ -100,13 +101,26 @@ export default function UnitMeasurementSummary(props: Props) {
); );
}; };
const grainName = () => {
if(component.settings.grainType === pond.Grain.GRAIN_CUSTOM && component.settings.customGrain?.name){
return component.settings.customGrain.name
}
return GrainDescriber(component.settings.grainType).name
}
const grainColor = () => {
if(component.settings.grainType === pond.Grain.GRAIN_CUSTOM && component.settings.customGrain?.name){
return stringToMaterialColour(component.settings.customGrain.name)
}
return GrainDescriber(component.settings.grainType).colour
}
const getSummaryComponent = (summaries: Summary[]): any => { const getSummaryComponent = (summaries: Summary[]): any => {
if (summaries.length < 1) { if (summaries.length < 1) {
return noSummary(); return noSummary();
} }
let overlays: JSX.Element[] = []; let overlays: JSX.Element[] = [];
let grain = GrainDescriber(component.settings.grainType);
return ( return (
<Grid container> <Grid container>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
@ -170,7 +184,7 @@ export default function UnitMeasurementSummary(props: Props) {
component.settings.grainType !== pond.Grain.GRAIN_INVALID && ( component.settings.grainType !== pond.Grain.GRAIN_INVALID && (
<Typography variant={largeText ? "body1" : "caption"} color="textPrimary"> <Typography variant={largeText ? "body1" : "caption"} color="textPrimary">
<span key={"grainType"}> <span key={"grainType"}>
Grain Type: <span style={{ color: grain.colour }}>{grain.name}</span> Grain Type: <span style={{ color: grainColor() }}>{grainName()}</span>
</span> </span>
</Typography> </Typography>
)} )}

View file

@ -56,55 +56,6 @@ export default function GateDevice(props: Props) {
const [lastTemps, setLastTemps] = useState({ t1: 0, t2: 0 }); const [lastTemps, setLastTemps] = useState({ t1: 0, t2: 0 });
const [lastPressures, setLastPressures] = useState({ p1: 0, p2: 0 }); const [lastPressures, setLastPressures] = useState({ p1: 0, p2: 0 });
// const StyledToggleButtonGroup = withStyles(theme => ({
// grouped: {
// margin: theme.spacing(-0.5),
// border: "none",
// padding: theme.spacing(1),
// "&:not(:first-child):not(:last-child)": {
// borderRadius: 24,
// marginRight: theme.spacing(0.5),
// marginLeft: theme.spacing(0.5)
// },
// "&:first-child": {
// borderRadius: 24,
// marginLeft: theme.spacing(0.25)
// },
// "&:last-child": {
// borderRadius: 24,
// marginRight: theme.spacing(0.25)
// }
// },
// root: {
// backgroundColor: darken(
// theme.palette.background.paper,
// getThemeType() === "light" ? 0.05 : 0.25
// ),
// borderRadius: 24,
// content: "border-box"
// }
// }))(ToggleButtonGroup);
// const StyledToggle = withStyles({
// root: {
// backgroundColor: "transparent",
// overflow: "visible",
// content: "content-box",
// "&$selected": {
// backgroundColor: "gold",
// color: "black",
// borderRadius: 24,
// fontWeight: "bold"
// },
// "&$selected:hover": {
// backgroundColor: "rgb(255, 255, 0)",
// color: "black",
// borderRadius: 24
// }
// },
// selected: {}
// })(ToggleButton);
useEffect(() => { useEffect(() => {
if (comprehensiveDevice.device) { if (comprehensiveDevice.device) {
setDevice(Device.any(comprehensiveDevice.device)); setDevice(Device.any(comprehensiveDevice.device));
@ -338,11 +289,11 @@ export default function GateDevice(props: Props) {
return ( return (
<Grid <Grid
container container
direction={(isMobile) ? "column" : "row"} // direction={(isMobile) ? "column" : "row"}
width="100%" width="100%"
alignItems="center" //alignItems="center"
> >
<Grid size={{ sm: 12, lg: isMobile || drawerView ? 12 : 4}} style={{ padding: 10 }}> <Grid width={"100%"} size={{ sm: 12, lg: isMobile || drawerView ? 12 : 4}} style={{ padding: 10 }}>
<Box <Box
display="flex" display="flex"
justifyContent="space-between" justifyContent="space-between"
@ -363,7 +314,7 @@ export default function GateDevice(props: Props) {
]} ]}
/> />
</Box> </Box>
<Card raised> <Card>
<GateSVG <GateSVG
finalTemp={ finalTemp={
gate.gateMutations[device.id().toString()] && gate.gateMutations[device.id().toString()] &&

View file

@ -70,8 +70,9 @@ export default function GateFlowGraph(props: Props) {
const classes = useStyles(); const classes = useStyles();
const [flowEvents, setFlowEvents] = useState<pond.AirFlowEvent[]>([]); const [flowEvents, setFlowEvents] = useState<pond.AirFlowEvent[]>([]);
const [eventsLoading, setEventsLoading] = useState(false); const [eventsLoading, setEventsLoading] = useState(false);
const eventThreshold = 5; //these two constants could be entered by the user at time of retrieval
const idleFlow = 2.44; const eventThreshold = 5; //the threshold that if crossed from one measurement to the next during a flow event will end the current flow event and start a new one
const idleFlow = 3.5; //the mass air flow that must be crossed in order to start a flow event, anything below this will not start an event but must go below this to end an event
useEffect(() => { useEffect(() => {
if (loadingChartData) return; if (loadingChartData) return;
@ -102,7 +103,6 @@ export default function GateFlowGraph(props: Props) {
timestamp: time.valueOf(), timestamp: time.valueOf(),
value: val.airFlow ?? 0 value: val.airFlow ?? 0
}; };
data.push(newPoint); data.push(newPoint);
if (!recent || recent.timestamp < newPoint.timestamp) { if (!recent || recent.timestamp < newPoint.timestamp) {
recent = newPoint; recent = newPoint;

View file

@ -333,7 +333,7 @@ export default function GateSVG(props: Props) {
const deviceCheckIn = () => { const deviceCheckIn = () => {
return ( return (
<g key={"deviceCheckIn"}> <g key={"deviceCheckIn"}>
<text x={95} y={12} fontSize={7} fontWeight={650} fill={"white"}> <text x={85} y={12} fontSize={7} fontWeight={650} fill={"white"}>
Last Checked In: {moment(checkInTime).fromNow()} Last Checked In: {moment(checkInTime).fromNow()}
</text> </text>
</g> </g>
@ -342,7 +342,7 @@ export default function GateSVG(props: Props) {
return ( return (
<React.Fragment> <React.Fragment>
<Box height={"100%"} maxWidth={isMobile ? 360 : 460} margin="auto"> <Box height={"100%"} maxWidth={isMobile ? 400 : 460} margin="auto">
<svg <svg
width={"100%"} width={"100%"}
height={"100%"} height={"100%"}

View file

@ -0,0 +1,213 @@
import {MenuItem, TextField } from "@mui/material"
import { cloneDeep } from "lodash"
import { pond } from "protobuf-ts/pond"
import React, { useEffect, useRef, useState } from "react"
interface Props {
grainSettings?: pond.GrainSettings
onGrainSettingsChange: (settings: pond.GrainSettings, formValid: boolean) => void
}
export default function CustomGrainForm(props: Props) {
const {grainSettings, onGrainSettingsChange} = props
const [newGrainSettings, setNewGrainSettings] = useState(pond.GrainSettings.create())
const [name, setName] = useState("")
const [group, setGroup] = useState("")
const [equation, setEquation] = useState<pond.MoistureEquation>(pond.MoistureEquation.MOISTURE_EQUATION_NONE)
const [constantA, setConstantA] = useState("0")
const [constantB, setConstantB] = useState("0")
const [constantC, setConstantC] = useState("0")
const [kgPerBushel, setKgPerBushel] = useState("0")
const [bushelsPerTonne, setBushelsPerTonne] = useState("0")
const valid = useRef(false)
useEffect(()=>{
if(grainSettings){
setName(grainSettings.name)
setGroup(grainSettings.group)
setEquation(grainSettings.equation)
setConstantA(grainSettings.a.toString())
setConstantB(grainSettings.b.toString())
setConstantC(grainSettings.c.toString())
setKgPerBushel(grainSettings.kgPerBushel.toString())
setBushelsPerTonne(grainSettings.bushelsPerTonne.toString())
setNewGrainSettings(grainSettings)
}
},[grainSettings])
const validate = (name: string, group: string, constA: string, constB: string, constC: string, kgPerBushel: string, bushelsPerTonne: string) => {
if (name === "") return false
if (group === "") return false
if (isNaN(parseFloat(constA))) return false
if (isNaN(parseFloat(constB))) return false
if (isNaN(parseFloat(constC))) return false
if (isNaN(parseFloat(kgPerBushel))) return false
if (isNaN(parseFloat(bushelsPerTonne))) return false
return true
}
const settingsChanged = (newSettings: pond.GrainSettings) => {
setNewGrainSettings(newSettings)
onGrainSettingsChange(newSettings, valid.current)
}
const grainForm = () => {
return (
<React.Fragment>
<TextField
sx={{marginBottom: 2}}
fullWidth
label="Name*"
value={name}
onChange={(e) => {
let name = e.target.value
setName(name)
let settings = cloneDeep(newGrainSettings)
settings.name = name
valid.current = validate(name, group, constantA, constantB, constantC, kgPerBushel, bushelsPerTonne)
settingsChanged(settings)
}}/>
<TextField
sx={{marginBottom: 2}}
fullWidth
label="Group*"
value={group}
onChange={(e) => {
let group = e.target.value
setGroup(group)
let settings = cloneDeep(newGrainSettings)
settings.group = group
valid.current = validate(name, group, constantA, constantB, constantC, kgPerBushel, bushelsPerTonne)
settingsChanged(settings)
}}/>
<TextField
sx={{marginBottom: 2}}
fullWidth
value={equation}
helperText={"Not selecting an EMC equation to use will return the humidity instead of EMC"}
label="EMC Equation"
onChange={(e) => {
let enumVal = parseFloat(e.target.value)
setEquation(enumVal)
let settings = cloneDeep(newGrainSettings)
settings.equation = enumVal
valid.current = validate(name, group, constantA, constantB, constantC, kgPerBushel, bushelsPerTonne)
settingsChanged(settings)
}}
select>
<MenuItem key={pond.MoistureEquation.MOISTURE_EQUATION_NONE} value={pond.MoistureEquation.MOISTURE_EQUATION_NONE}>
None
</MenuItem>
<MenuItem key={pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST} value={pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST}>
Chung-Pfost
</MenuItem>
<MenuItem key={pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON} value={pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON}>
Henderson
</MenuItem>
<MenuItem key={pond.MoistureEquation.MOISTURE_EQUATION_HALSEY} value={pond.MoistureEquation.MOISTURE_EQUATION_HALSEY}>
Halsey
</MenuItem>
<MenuItem key={pond.MoistureEquation.MOISTURE_EQUATION_OSWIN} value={pond.MoistureEquation.MOISTURE_EQUATION_OSWIN}>
Oswin
</MenuItem>
</TextField>
<TextField
sx={{marginBottom: 2}}
fullWidth
type="number"
value={constantA}
error={isNaN(parseFloat(constantA))}
helperText={isNaN(parseFloat(constantA)) ? "Must be a valid number" : ""}
onChange={(e) => {
let val = e.target.value
setConstantA(val)
valid.current = validate(name, group, val, constantB, constantC, kgPerBushel, bushelsPerTonne)
let settings = cloneDeep(newGrainSettings)
if(!isNaN(parseFloat(val))){
settings.a = parseFloat(val)
}
settingsChanged(settings)
}}
label="Constant A"/>
<TextField
sx={{marginBottom: 2}}
fullWidth
type="number"
value={constantB}
error={isNaN(parseFloat(constantB))}
helperText={isNaN(parseFloat(constantB)) ? "Must be a valid number" : ""}
onChange={(e) => {
let val = e.target.value
setConstantB(val)
valid.current = validate(name, group, constantA, val, constantC, kgPerBushel, bushelsPerTonne)
let settings = cloneDeep(newGrainSettings)
if(!isNaN(parseFloat(val))){
settings.b = parseFloat(val)
}
settingsChanged(settings)
}}
label="Constant B"/>
<TextField
sx={{marginBottom: 2}}
fullWidth
type="number"
value={constantC}
error={isNaN(parseFloat(constantC))}
helperText={isNaN(parseFloat(constantC)) ? "Must be a valid number" : ""}
onChange={(e) => {
let val = e.target.value
setConstantC(val)
valid.current = validate(name, group, constantA, constantB, val, kgPerBushel, bushelsPerTonne)
let settings = cloneDeep(newGrainSettings)
if(!isNaN(parseFloat(val))){
settings.c = parseFloat(val)
}
settingsChanged(settings)
}}
label="Constant C"/>
<TextField
sx={{marginBottom: 2}}
fullWidth
type="number"
value={kgPerBushel}
error={isNaN(parseFloat(kgPerBushel))}
helperText={isNaN(parseFloat(kgPerBushel)) ? "Must be a valid number" : ""}
onChange={(e) => {
let val = e.target.value
setKgPerBushel(val)
valid.current = validate(name, group, constantA, constantB, constantC, val, bushelsPerTonne)
let settings = cloneDeep(newGrainSettings)
if(!isNaN(parseFloat(val))){
settings.kgPerBushel = parseFloat(val)
}
settingsChanged(settings)
}}
label="kg per Bushel"/>
<TextField
sx={{marginBottom: 2}}
fullWidth
type="number"
value={bushelsPerTonne}
error={isNaN(parseFloat(bushelsPerTonne))}
helperText={isNaN(parseFloat(bushelsPerTonne)) ? "Must be a valid number" : ""}
onChange={(e) => {
let val = e.target.value
setBushelsPerTonne(val)
valid.current = validate(name, group, constantA, constantB, constantC, kgPerBushel, val)
let settings = cloneDeep(newGrainSettings)
if(!isNaN(parseFloat(val))){
settings.bushelsPerTonne = parseFloat(val)
}
settingsChanged(settings)
}}
label="Bushels per Tonne"/>
</React.Fragment>
)
}
return (
<React.Fragment>
{grainForm()}
</React.Fragment>
)
}

View file

@ -0,0 +1,155 @@
import { ExpandMore } from "@mui/icons-material"
import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Typography } from "@mui/material"
import SearchSelect, { Option } from "common/SearchSelect"
import { pond } from "protobuf-ts/pond"
import { useGlobalState } from "providers"
import { useGrainAPI } from "providers/pond/grainAPI"
import React, { useEffect, useState } from "react"
import CustomGrainForm from "./CustomGrainForm"
interface Props {
initialGrain?: pond.GrainSettings
onGrainSettingsChange: (settings: pond.GrainSettings) => void
}
export default function CustomGrainSelector(props: Props) {
const {initialGrain, onGrainSettingsChange} = props
const grainAPI = useGrainAPI()
const [{as}] = useGlobalState()
const [newGrainSettings, setNewGrainSettings] = useState(initialGrain ?? pond.GrainSettings.create())
const [formValid, setFormValid] = useState(true)
const [grainMap, setGrainMap] = useState<Map<string, pond.GrainObject>>(
initialGrain ?
new Map([
["Active", pond.GrainObject.create({key: "Active", name: initialGrain.name, settings: initialGrain})]
])
:
new Map())
const [grainOptions, setGrainOptions] = useState<Option[]>(
initialGrain ?
[
{
label: initialGrain.name,
value: "Active",
group: "Active"
}
]
:
[]
)
const [isNew, setIsNew] = useState(false)
const [selectedOption, setSelectedOption] = useState<Option | null>(initialGrain ? {
label: initialGrain.name,
value: "Active",
group: "Active"
} : null)
useEffect(()=>{
grainAPI.listGrains(0, 0, "asc", "name", undefined, undefined, undefined, as).then(resp => {
let options: Option[] = grainOptions
let map: Map<string, pond.GrainObject> = grainMap
if (resp.data.grains){
resp.data.grains.forEach(grain => {
map.set(grain.key, grain)
options.push({
label: grain.name,
value: grain.key,
group: grain.settings?.group,
})
})
}
setGrainOptions([...options])
setGrainMap(map)
})
},[grainAPI])
const saveGrain = () => {
// console.log(newGrainSettings)
grainAPI.addGrain(newGrainSettings, as).then(resp => {
console.log("grain added")
}).catch(err => {
console.log("err")
})
}
const settingsChanged = (newSettings: pond.GrainSettings, formValid: boolean) => {
setNewGrainSettings(newSettings)
setIsNew(true)
setFormValid(formValid)
onGrainSettingsChange(newSettings)
}
const grainAccordion = () => {
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>Custom Grain Properties</AccordionSummary>
<AccordionDetails>
<CustomGrainForm grainSettings={newGrainSettings} onGrainSettingsChange={settingsChanged}/>
{isNew &&
<Box display='flex' justifyContent="flex-end">
<Button
variant="contained"
disabled={!formValid}
onClick={() => {
setIsNew(false)
saveGrain()
}}
color="primary">
Save Custom Grain
</Button>
</Box>
}
</AccordionDetails>
</Accordion>
)
}
return (
<React.Fragment>
{grainOptions.length === 0 ?
<Box>
<Typography sx={{marginBottom: 1}}>Custom Grain Properties</Typography>
<CustomGrainForm grainSettings={newGrainSettings} onGrainSettingsChange={settingsChanged}/>
{isNew &&
<Box display='flex' justifyContent="flex-end">
<Button
variant="contained"
disabled={!formValid}
onClick={() => {
setIsNew(false)
}}
color="primary">
Save Custom Grain
</Button>
</Box>
}
</Box>
:
<Box>
<Box sx={{marginBottom: 2}}>
<SearchSelect
label="My Grains"
selected={selectedOption}
changeSelection={option => {
setIsNew(false)
setSelectedOption(option)
//when an option is selected set all of the state variables controlling the form entries
let grain = grainMap.get(option?.value)
if(grain && grain.settings){
//the the new grain settings object
setNewGrainSettings(grain.settings)
//pass that settings object back up
settingsChanged(grain.settings, true)
}
}}
group
options={grainOptions}
/>
</Box>
{grainAccordion()}
</Box>
}
</React.Fragment>
)
}

View file

@ -641,6 +641,16 @@ export function GetGrainExtensionMap(): Map<pond.Grain, GrainExtension> {
return cloneDeep(GrainExtensions); return cloneDeep(GrainExtensions);
} }
export function GetValidGrainExtensions(): GrainExtension[] {
let validGrains: GrainExtension[] = []
GrainExtensions.forEach((ext, key) => {
if(key !== pond.Grain.GRAIN_NONE && key !== pond.Grain.GRAIN_INVALID && key !== pond.Grain.GRAIN_CUSTOM){
validGrains.push(ext)
}
})
return validGrains
}
export default function GrainDescriber(type: pond.Grain): GrainExtension { export default function GrainDescriber(type: pond.Grain): GrainExtension {
let describer = GrainExtensions.get(type); let describer = GrainExtensions.get(type);
//console.log(describer) //console.log(describer)

View file

@ -16,7 +16,8 @@ const toERH = (humidity: number): number => {
export function ExtractMoisture( export function ExtractMoisture(
type: pond.Grain | undefined, type: pond.Grain | undefined,
celsius: number, celsius: number,
humidity: number humidity: number,
customGrain?: pond.GrainSettings
): number { ): number {
if (humidity <= 0) { if (humidity <= 0) {
return 0; return 0;
@ -26,20 +27,43 @@ export function ExtractMoisture(
type === undefined || type === undefined ||
type === pond.Grain.GRAIN_NONE || type === pond.Grain.GRAIN_NONE ||
type === pond.Grain.GRAIN_INVALID || type === pond.Grain.GRAIN_INVALID ||
type === pond.Grain.GRAIN_CUSTOM (type === pond.Grain.GRAIN_CUSTOM && customGrain === undefined)
) { ) {
return humidity; return humidity;
} }
let dry = humidity; let dry = humidity;
let eq: pond.MoistureEquation
let constA: number
let constB: number
let constC: number
if(type === pond.Grain.GRAIN_CUSTOM && customGrain){
if(customGrain.equation === pond.MoistureEquation.MOISTURE_EQUATION_NONE) return humidity
eq = customGrain.equation
constA = customGrain.a
constB = customGrain.b
constC = customGrain.c
}else{
let ctx = GrainDescriber(type); let ctx = GrainDescriber(type);
dry = toDryMoisture(ctx.equation, celsius, humidity, ctx.a, ctx.b, ctx.c); eq = ctx.equation
constA = ctx.a
constB = ctx.b
constC = ctx.c
}
dry = toDryMoisture(eq, celsius, humidity, constA, constB, constC);
return dryToWet(dry); return dryToWet(dry);
} }
export function WaterContent(type: pond.Grain, bushels: number, moistureContent: number): number { export function WaterContent(type: pond.Grain, bushels: number, moistureContent: number, customGrain?: pond.GrainSettings): number {
let grain = GrainDescriber(type); //kg per bushel conversion
return bushels * grain.weightConversionKg * moistureContent; let conversion = 0
if(type === pond.Grain.GRAIN_CUSTOM && customGrain){
conversion = customGrain.kgPerBushel
}else{
conversion = GrainDescriber(type).weightConversionKg;
}
return bushels * conversion * moistureContent;
} }
export function MoistureToHumidity( export function MoistureToHumidity(
@ -69,7 +93,7 @@ function wetToDry(wetMC: number): number {
} }
function toDryMoisture( function toDryMoisture(
eq: Equation, eq: pond.MoistureEquation,
T: number, T: number,
RH: number, RH: number,
a: number, a: number,
@ -78,13 +102,13 @@ function toDryMoisture(
): number { ): number {
const ERH: number = toERH(RH); const ERH: number = toERH(RH);
switch (eq) { switch (eq) {
case Equation.chungPfost: case pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST:
return chungPfost(T, ERH, a, b, c); return chungPfost(T, ERH, a, b, c);
case Equation.halsey: case pond.MoistureEquation.MOISTURE_EQUATION_HALSEY:
return halsey(T, ERH, a, b, c); return halsey(T, ERH, a, b, c);
case Equation.henderson: case pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON:
return henderson(T, ERH, a, b, c); return henderson(T, ERH, a, b, c);
case Equation.oswin: case pond.MoistureEquation.MOISTURE_EQUATION_OSWIN:
return oswin(T, ERH, a, b, c); return oswin(T, ERH, a, b, c);
default: default:
return RH; return RH;
@ -92,7 +116,7 @@ function toDryMoisture(
} }
function dryMoistureToHumidity( function dryMoistureToHumidity(
eq: Equation, eq: pond.MoistureEquation,
celsius: number, celsius: number,
dryMC: number, dryMC: number,
a: number, a: number,
@ -101,16 +125,16 @@ function dryMoistureToHumidity(
): number { ): number {
let ERH = 0; let ERH = 0;
switch (eq) { switch (eq) {
case Equation.chungPfost: case pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST:
ERH = chungPfostInverse(celsius, dryMC, a, b, c); ERH = chungPfostInverse(celsius, dryMC, a, b, c);
break; break;
case Equation.halsey: case pond.MoistureEquation.MOISTURE_EQUATION_HALSEY:
ERH = halseyInverse(celsius, dryMC, a, b, c); ERH = halseyInverse(celsius, dryMC, a, b, c);
break; break;
case Equation.henderson: case pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON:
ERH = hendersonInverse(celsius, dryMC, a, b, c); ERH = hendersonInverse(celsius, dryMC, a, b, c);
break; break;
case Equation.oswin: case pond.MoistureEquation.MOISTURE_EQUATION_OSWIN:
ERH = oswinInverse(celsius, dryMC, a, b, c); ERH = oswinInverse(celsius, dryMC, a, b, c);
break; break;
default: default:

View file

@ -125,6 +125,7 @@ export default function GrainTransaction(props: Props) {
.then(resp => { .then(resp => {
//let sourceOps: Option[] = sourceOptions //let sourceOps: Option[] = sourceOptions
let binOps: Option[] = []; let binOps: Option[] = [];
if(resp.data.bins){
resp.data.bins.forEach(bin => { resp.data.bins.forEach(bin => {
let b = Bin.create(bin); let b = Bin.create(bin);
if (mainObject.key() !== b.key()) { if (mainObject.key() !== b.key()) {
@ -133,9 +134,10 @@ export default function GrainTransaction(props: Props) {
value: b, value: b,
group: "Bins" group: "Bins"
}; };
(!restrictMatching || grainTypesMatch(op.value, mainObject)) && binOps.push(op); (!restrictMatching || grainTypesMatch(op.value, mainObject) || op.value.grainName() === mainObject.grainName()) && binOps.push(op);
} }
}); });
}
setBinOptions([...binOps]); setBinOptions([...binOps]);
}) })
.finally(() => { .finally(() => {
@ -151,6 +153,7 @@ export default function GrainTransaction(props: Props) {
.then(resp => { .then(resp => {
//let sourceOps: Option[] = sourceOptions //let sourceOps: Option[] = sourceOptions
let bagOps: Option[] = []; let bagOps: Option[] = [];
if(resp.data.grainBags){
resp.data.grainBags.forEach(bag => { resp.data.grainBags.forEach(bag => {
let b = GrainBag.create(bag); let b = GrainBag.create(bag);
if (mainObject.key() !== b.key()) { if (mainObject.key() !== b.key()) {
@ -159,9 +162,10 @@ export default function GrainTransaction(props: Props) {
value: b, value: b,
group: "Grain Bags" group: "Grain Bags"
}; };
(!restrictMatching || grainTypesMatch(op.value, mainObject)) && bagOps.push(op); (!restrictMatching || grainTypesMatch(op.value, mainObject) || op.value.grainName() === mainObject.grainName()) && bagOps.push(op);
} }
}); });
}
setBagOptions([...bagOps]); setBagOptions([...bagOps]);
}) })
.finally(() => { .finally(() => {
@ -184,7 +188,7 @@ export default function GrainTransaction(props: Props) {
value: f, value: f,
group: "Fields" group: "Fields"
}; };
(!restrictMatching || grainTypesMatch(op.value, mainObject)) && fieldOps.push(op); (!restrictMatching || grainTypesMatch(op.value, mainObject) || op.value.grainName() === mainObject.grainName()) && fieldOps.push(op);
}); });
setFieldOptions([...fieldOps]); setFieldOptions([...fieldOps]);
}) })
@ -356,13 +360,6 @@ export default function GrainTransaction(props: Props) {
if (source.grain() === destination.grain()) { if (source.grain() === destination.grain()) {
matching = true; matching = true;
} }
} else if (source.storage() === pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN) {
if (
source.customType().toLowerCase() === destination.customType().toLowerCase() &&
source.bushelsPerTonne() === destination.bushelsPerTonne()
) {
matching = true;
}
} }
} }
return matching; return matching;
@ -373,7 +370,7 @@ export default function GrainTransaction(props: Props) {
if (grainTypesMatch(selectedSource.value, selectedDestination.value)) { if (grainTypesMatch(selectedSource.value, selectedDestination.value)) {
updateInventory(); updateInventory();
} else { } else {
//open dialog saying that the destinations grain type will change from this action //open dialog saying that the grain types do not match or because it is a custom type
setGrainChangeDialog(true); setGrainChangeDialog(true);
} }
} else { } else {
@ -381,6 +378,36 @@ export default function GrainTransaction(props: Props) {
} }
}; };
/**
* the function is used to determine what text to display to the user when the grain types dont match
* or when it sees any of the objects involved as having a custom grain type
* @param source the source of the grain
* @param destination the destination of the grain
* @returns the text describing the situation
*/
const confirmationText = () => {
if(selectedSource && selectedDestination){
let source = selectedSource?.value
let destination = selectedDestination?.value
//both are custom
if(source.storage() === pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN && destination.storage() === pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN){
return "Both source and destination are using custom grain types, they may not match. Would you like to continue with the transaction?"
}
//source is custom destination is not
if(source.storage() === pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN){
return "The source is using a custom grain type and may not match the destination. Would you like to continue with the transaction?"
}
//destination is custom source is not
if(destination.storage() === pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN){
return "The destination is using a custom grain type and may not match the source. Would you like to continue with the transaction?"
}
}
//neither are custom
return "The Grain types do not match. Are you sure you would like to perform this transaction?"
}
const grainChange = () => { const grainChange = () => {
return ( return (
<ResponsiveDialog <ResponsiveDialog
@ -389,7 +416,7 @@ export default function GrainTransaction(props: Props) {
setGrainChangeDialog(false); setGrainChangeDialog(false);
}}> }}>
<DialogContent> <DialogContent>
The grain type of the destination may change as a result of this action. {confirmationText()}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button <Button

View file

@ -90,11 +90,19 @@ export class Bin {
public customType(): string { public customType(): string {
let c = ""; let c = "";
if (this.settings.inventory) { if (this.settings.inventory) {
if (this.settings.inventory.customGrain){
c = this.settings.inventory.customGrain.name
}else{
c = this.settings.inventory.customTypeName; c = this.settings.inventory.customTypeName;
} }
}
return c; return c;
} }
public customGrain(): pond.GrainSettings | undefined {
return this.settings.inventory?.customGrain ?? undefined
}
public empty(): boolean { public empty(): boolean {
return this.settings.inventory?.empty || (this.settings.inventory !== undefined && this.settings.inventory !== null && this.bushels() < 5); return this.settings.inventory?.empty || (this.settings.inventory !== undefined && this.settings.inventory !== null && this.bushels() < 5);
} }

View file

@ -142,4 +142,9 @@ export class Component {
return getFriendlyAddressTypeName(this.settings.addressType); return getFriendlyAddressTypeName(this.settings.addressType);
} }
}; };
public customGrainProps(): pond.GrainSettings | undefined {
if(this.settings.customGrain) return this.settings.customGrain
return
}
} }

View file

@ -48,6 +48,7 @@ const JohnDeere = lazy(() => import("pages/JohnDeere"));
const CNHi = lazy(() => import("pages/CNHi")); const CNHi = lazy(() => import("pages/CNHi"));
const LibraCart = lazy(() => import("pages/LibraCart")); const LibraCart = lazy(() => import("pages/LibraCart"));
const FieldPage = lazy(()=>import("pages/Field")); const FieldPage = lazy(()=>import("pages/Field"));
const GrainsPage = lazy(()=>import("pages/Grains"));
export const appendToUrl = (appendage: number | string) => { export const appendToUrl = (appendage: number | string) => {
const basePath = location.pathname.replace(/\/$/, ""); const basePath = location.pathname.replace(/\/$/, "");
@ -360,6 +361,7 @@ export default function Router() {
{user.hasFeature("libra-cart") && {user.hasFeature("libra-cart") &&
<Route path="libracart" element={<LibraCart />} /> <Route path="libracart" element={<LibraCart />} />
} }
<Route path="grains" element={<GrainsPage />} />
{/* Map routes */} {/* Map routes */}
<Route path="visualFarm" element={<FieldMap />} /> <Route path="visualFarm" element={<FieldMap />} />
<Route path="aviationMap" element={<AviationMap />} /> <Route path="aviationMap" element={<AviationMap />} />

View file

@ -1,4 +1,4 @@
import { ChevronRight, Code, Memory, People, Person, SyncAlt, TapAndPlay } from "@mui/icons-material"; import { ChevronRight, Code, Grain, Memory, People, Person, SyncAlt, TapAndPlay } from "@mui/icons-material";
import ChevronLeft from "@mui/icons-material/ChevronLeft"; import ChevronLeft from "@mui/icons-material/ChevronLeft";
import { import {
darken, darken,
@ -374,6 +374,20 @@ export default function SideNavigator(props: Props) {
</ListItemButton> </ListItemButton>
</Tooltip> </Tooltip>
)} )}
{(isAg || isStreamline || user.hasFeature("admin")) && (
<Tooltip title="Grain Types" placement="right">
<ListItemButton
id="tour-grain-types"
onClick={() => goTo("/grains")}
classes={getClasses("/grains")}
>
<ListItemIcon>
<Grain />
</ListItemIcon>
{open && <ListItemText primary="Grain Types" />}
</ListItemButton>
</Tooltip>
)}
{(isAdCon || user.hasFeature("admin")) && ( {(isAdCon || user.hasFeature("admin")) && (
<Tooltip title="Jobsites" placement="right"> <Tooltip title="Jobsites" placement="right">

View file

@ -819,6 +819,7 @@ export default function Bin(props: Props) {
fans={fans} fans={fans}
heaters={heaters} heaters={heaters}
grain={bin.grain()} grain={bin.grain()}
customGrain={bin.customGrain()}
/> />
)} )}
<Box marginTop={2}> <Box marginTop={2}>
@ -968,6 +969,7 @@ export default function Bin(props: Props) {
fans={fans} fans={fans}
heaters={heaters} heaters={heaters}
grain={bin.grain()} grain={bin.grain()}
customGrain={bin.customGrain()}
/> />
</Box> </Box>
)} )}

View file

@ -196,9 +196,10 @@ export default function Bins(props: Props) {
const { openSnack } = useSnackbar(); const { openSnack } = useSnackbar();
// const history = useHistory(); // const history = useHistory();
const navigate = useNavigate() const navigate = useNavigate()
const [cardValDisplay, setCardValDisplay] = useState<"high" | "low" | "average">("average"); const [cardValDisplay, setCardValDisplay] = useState<"high" | "low" | "average">("high");
const [teamsDialog, setTeamsDialog] = useState(false) const [teamsDialog, setTeamsDialog] = useState(false)
const snackbar = useSnackbar()
// const [scrollTranslations, setScrollTranslations] = useState({ // const [scrollTranslations, setScrollTranslations] = useState({
// bins: 0, // bins: 0,
@ -261,10 +262,12 @@ export default function Bins(props: Props) {
if (doneLoadingPage && grainBins.length < 1 && grainBags.length < 1) { if (doneLoadingPage && grainBins.length < 1 && grainBags.length < 1) {
teamAPI.listTeams(1, 0).then(resp => { teamAPI.listTeams(1, 0).then(resp => {
// console.log(resp.data.teams) // console.log(resp.data.teams)
if (resp.data.teams.length > 0) { if (resp.data?.teams?.length > 0) {
// console.log("should?") // console.log("should?")
setTeamsDialog(true) setTeamsDialog(true)
} }
}).catch(err => {
snackbar.error("error listing teams: "+err)
}) })
} }
}, [doneLoadingPage, grainBins, grainBags]) }, [doneLoadingPage, grainBins, grainBags])

View file

@ -158,9 +158,9 @@ export default function DevicePage() {
if (length > 0) { if (length > 0) {
warningString = warningString + " through " + getContextTypes()[length - 1]; warningString = warningString + " through " + getContextTypes()[length - 1];
} }
snackbar.warning(warningString); snackbar.warning("page data warning: "+warningString);
} else { } else {
snackbar.error(err); snackbar.error("page data error: "+err);
} }
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@ -174,7 +174,7 @@ export default function DevicePage() {
} }
}) })
.catch(err => { .catch(err => {
console.log(err) console.log("load port scan: "+err)
}) })
} }

474
src/pages/Grains.tsx Normal file
View file

@ -0,0 +1,474 @@
import { useCallback, useEffect, useState } from "react";
import PageContainer from "./PageContainer";
import CustomGrainForm from "grain/CustomGrainForm";
import { Accordion, AccordionDetails, AccordionSummary, Box, Button, DialogActions, DialogContent, DialogTitle, Divider, Grid2, IconButton, Typography } from "@mui/material";
import { pond } from "protobuf-ts/pond";
import ButtonGroup from "common/ButtonGroup";
import { useMobile, useSnackbar, useUserAPI } from "hooks";
import { useGrainAPI } from "providers/pond/grainAPI";
import ResponsiveTable from "common/ResponsiveTable";
import { GetGrainExtensionMap, GetValidGrainExtensions, GrainExtension } from "grain";
import ResponsiveDialog from "common/ResponsiveDialog";
import { useGlobalState } from "providers";
import { Scope } from "models";
import React from "react";
import { AddCircle, ExpandMore } from "@mui/icons-material";
export default function Grains() {
const [currentCustomGrain, setCurrentCustomGrain] = useState<pond.GrainSettings>()
const [formValid, setFormValid] = useState(false)
const [selectedGrain, setSelectedGrain] = useState<pond.GrainObject | undefined>()
const [displayCustom, setDisplayCustom] = useState(false)
const { openSnack } = useSnackbar()
const [tableSize, setTableSize] = useState(10)
const [tablePage, setTablePage] = useState(0)
const [tableTotal, setTableTotal] = useState(0)
const grainAPI = useGrainAPI()
const userAPI = useUserAPI()
const [{as, user}] = useGlobalState();
const [permissions, setPermissions] = useState<pond.Permission[]>([])
const [openRemoveConfirmation, setOpenRemoveConfirmation] = useState(false)
const [openUpdateConfirmation, setOpenUpdateConfirmation] = useState(false)
const [grainObjectTableData, setGrainObjectTableData] = useState<pond.GrainObject[]>([])
const [supportedGrainTableData, setSupportedGrainTableData] = useState<GrainExtension[]>([])
// const [selectedGrainExtension, setSelectedGrainExtension] = useState<GrainExtension | undefined>()
const [accordionKey, setAccordionKey] = useState("allClosed")
const [openNewGrainDialog, setOpenNewGrainDialog] = useState(false)
const isMobile = useMobile()
const extraLoad = 10 //the amount of more to load on the mobile view when load more is clicked
//function to load the first set of custom grains
const loadCustomGrain = useCallback(() => {
grainAPI.listGrains(tableSize, tablePage * tableSize, "asc", "name").then(resp => {
setGrainObjectTableData(resp.data.grains)
setTableTotal(resp.data.total)
}).catch(err => {
openSnack("There was an issue loading your custom grains")
})
},[tableSize, tablePage])
useEffect(() => {
//load the custom grains
if(displayCustom){
loadCustomGrain()
}else{
let displayedGrains: GrainExtension[] = []
let offset = tablePage * tableSize
let limit = tableSize
let grainList = GetValidGrainExtensions()
let sortedGrain = grainList.sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1))
sortedGrain.forEach((ext, i) => {
if(i >= offset && displayedGrains.length < limit){
displayedGrains.push(ext)
}
})
setSupportedGrainTableData(displayedGrains)
setTableTotal(sortedGrain.length)
}
},[loadCustomGrain, tableSize, tablePage, displayCustom, as])
useEffect(()=>{
//if the user is viewing as a team get their permissions to the team, if they are not then we will get their permission to the grain type individually when a type is selected
if(as){
userAPI.getUser(user.id(), { key: as, kind: "team" } as Scope).then(resp => {
setPermissions(resp.permissions);
});
}
},[as, userAPI])
const equationName = (enumVal: number) => {
switch(enumVal){
case pond.MoistureEquation.MOISTURE_EQUATION_CHUNG_PFOST:
return "Chung-Pfost"
case pond.MoistureEquation.MOISTURE_EQUATION_HALSEY:
return "Halsey"
case pond.MoistureEquation.MOISTURE_EQUATION_HENDERSON:
return "Henderson"
case pond.MoistureEquation.MOISTURE_EQUATION_OSWIN:
return "Oswin"
default:
return "None"
}
}
//function to update
//table that will display the custom grains created by the user
const grainObjectTable = () => {
return (
<ResponsiveTable<pond.GrainObject>
rows={grainObjectTableData}
noDataMessage="No Grain Types Found. Add a new custom grain to see them here"
loadMore={() => {
setTableSize(tableSize + extraLoad)
}}
renderMobile={(row) => <Accordion expanded={accordionKey === row.key} onChange={() => {
//close it if it is already open
if(accordionKey === row.key){
setAccordionKey("allClosed")
}else{
setAccordionKey(row.key)
}
setCurrentCustomGrain(row.settings ?? pond.GrainSettings.create())
}}>
<AccordionSummary expandIcon={<ExpandMore />}>{row.name}</AccordionSummary>
<AccordionDetails>
{customGrainDisplay()}
</AccordionDetails>
</Accordion>}
onRowClick={(grainObj) => {
setSelectedGrain(grainObj)
setFormValid(true)
/** if they are viewin as a team leave the permissions alone, if not then use the users permissions to the grain type,
* for the moment grain types are not shareable so this will be unused, but this will cover our bases if we want to implement sharing in the future
* */
if (!as) {
userAPI.getUser(user.id(), { key: grainObj.key, kind: "grain" } as Scope).then(resp => {
setPermissions(resp.permissions);
});
}
}}
columns={[
{
title: "Name",
render: row => <Box padding={2}>
<Typography>
{row.name}
</Typography>
</Box>
},
{
title: "Group",
render: row => <Box padding={2}>
<Typography>
{row.settings?.group}
</Typography>
</Box>
},
{
title: "Equation",
render: row => <Box padding={2}>
<Typography>
{equationName(row.settings?.equation ?? 0)}
</Typography>
</Box>
}
]}
page={tablePage}
pageSize={tableSize}
handleRowsPerPageChange={(e) => {
setTableSize(e.target.value)
}}
setPage={(page) => {
setTablePage(page)
}}
total={tableTotal}
/>
)
}
//table that will display the grain extensions provided by us
const supportedGrainTable = () => {
return (
<ResponsiveTable<GrainExtension>
rows={supportedGrainTableData}
page={tablePage}
// onRowClick={(ext) => {setSelectedGrainExtension(ext)}}
loadMore={() => {
setTableSize(tableSize + extraLoad)
}}
renderMobile={(row) => <Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>{row.name}</AccordionSummary>
<AccordionDetails>
{supportedDisplay(row)}
</AccordionDetails>
</Accordion>}
pageSize={tableSize}
columns={[
{
title: "Name",
render: row => <Box padding={2}>
<Typography>
{row.name}
</Typography>
</Box>
},
{
title: "Group",
render: row => <Box padding={2}>
<Typography>
{row.group}
</Typography>
</Box>
},
{
title: "Equation",
render: row => <Box padding={2}>
<Typography>
{equationName(row.equation ?? 0)}
</Typography>
</Box>
},
{
title: "Kg Per Bushel",
render: row => <Box padding={2}>
<Typography>
{row.weightConversionKg.toFixed(2)}
</Typography>
</Box>
},
{
title: "Bushels Per Tonne",
render: row => <Box padding={2}>
<Typography>
{row.bushelsPerTonne.toFixed(2)}
</Typography>
</Box>
}
]}
handleRowsPerPageChange={(e) => {
setTableSize(e.target.value)
}}
setPage={(page) => {
setTablePage(page)
}}
total={tableTotal}
/>
)
}
//the grain display for supported grain type to show the rest of the data such as what constants are used in the formula
const supportedDisplay = (grain?: GrainExtension) => {
return (
<Box>
{grain ?
<React.Fragment>
<Typography>
Info
</Typography>
<Divider />
<Box padding={2}>
<Typography>Name: {grain.name}</Typography>
<Typography>Group: {grain.group}</Typography>
</Box>
<Typography>
Formula
</Typography>
<Divider />
<Box padding={2}>
<Typography>Equation: {equationName(grain.equation)}</Typography>
{/* <Typography>Constant A: {grain.a}</Typography>
<Typography>Constant B: {grain.b}</Typography>
<Typography>Constant C: {grain.c}</Typography> */}
</Box>
<Typography>
Conversions
</Typography>
<Divider />
<Box padding={2}>
<Typography>Kg per Bushel: {grain.weightConversionKg.toFixed(2)}</Typography>
<Typography>Bushels per Tonne: {grain.bushelsPerTonne.toFixed(2)}</Typography>
</Box>
</React.Fragment>
:
<Typography>Select a Grain from the table to view its details</Typography>
}
</Box>
)
}
const updateGrain = () => {
if(selectedGrain && currentCustomGrain){
grainAPI.updateGrain(selectedGrain.key, currentCustomGrain).then(resp => {
openSnack("Grain type updated")
}).catch(err => {
openSnack("There was an issue updating the selected grain type")
}).finally(() => {
setOpenUpdateConfirmation(false)
loadCustomGrain()
})
}
}
const updateGrainConfirmation = () => {
return (
<ResponsiveDialog open={openUpdateConfirmation} onClose={() => {setOpenUpdateConfirmation(false)}}>
<DialogTitle>Update Custom Grain</DialogTitle>
<DialogContent>
Update existing custom grain type?
<br/>
Note that updating the grain here will not change any bins or components this type was used for, those will retain the old settings.
</DialogContent>
<DialogActions>
<Button variant="contained" color="primary" onClick={()=>{setOpenUpdateConfirmation(false)}}>Cancel</Button>
<Button variant="contained" color="primary" onClick={()=>{updateGrain()}}>Confirm</Button>
</DialogActions>
</ResponsiveDialog>
)
}
//because the form is embedded on desktop this dialog is specifically for mobile view
const newGrainDialog = () => {
return (
<ResponsiveDialog open={openNewGrainDialog} onClose={() => setOpenNewGrainDialog(false)}>
<DialogTitle>Add New Grain</DialogTitle>
<DialogActions>
{customGrainDisplay()}
</DialogActions>
</ResponsiveDialog>
)
}
const addNewGrain = () => {
if(currentCustomGrain){
grainAPI.addGrain(currentCustomGrain).then(resp => {
//new grain added
openSnack("New grain type added")
}).catch(err => {
openSnack("There was a problem creating the new grain type")
}).finally(() => {
loadCustomGrain()
setOpenNewGrainDialog(false)//only for the mobile view since the desktop has it embedded
})
}
}
const removeGrain = () => {
if(selectedGrain){
grainAPI.removeGrain(selectedGrain.key).then(resp => {
openSnack("Grain type removed")
}).catch(err => {
openSnack("There was a problem removing the selected grain type")
}).finally(() => {
setOpenRemoveConfirmation(false)
loadCustomGrain()
})
}
}
const removeConfirmation = () => {
return (
<ResponsiveDialog open={openRemoveConfirmation} onClose={() => {setOpenRemoveConfirmation(false)}}>
<DialogTitle>Remove Custom Grain</DialogTitle>
<DialogContent>
Remove custom grain type?
<br />
Note that removing the grain here will not change any bins or components this type was used for, those will retain the old settings.
</DialogContent>
<DialogActions>
<Button variant="contained" color="primary" onClick={()=>{setOpenRemoveConfirmation(false)}}>Cancel</Button>
<Button variant="contained" color="primary" onClick={()=>{removeGrain()}}>Confirm</Button>
</DialogActions>
</ResponsiveDialog>
)
}
const customGrainActions = () => {
return (
<Box display="flex" justifyContent="space-between">
<Box>
{selectedGrain &&
<Button disabled={!permissions.includes(pond.Permission.PERMISSION_WRITE)} variant="contained" color="error" onClick={() => {setOpenRemoveConfirmation(true)}}>Remove Grain</Button>
}
</Box>
<Box>
{selectedGrain &&
<Button sx={{marginRight: 1}} onClick={() => setOpenUpdateConfirmation(true)} disabled={!formValid || !permissions.includes(pond.Permission.PERMISSION_WRITE)} variant="contained" color="primary">Update</Button>
}
{isMobile && !selectedGrain && <Button onClick={() => {setOpenNewGrainDialog(false)}}>Cancel</Button>}
<Button onClick={() => addNewGrain()} disabled={!formValid || (as !== "" && !permissions.includes(pond.Permission.PERMISSION_WRITE))} variant="contained" color="primary">Save New</Button>
</Box>
</Box>
)
}
//the grain display for custom grain the user has defined
const customGrainDisplay = () => {
return (
<Box>
<CustomGrainForm
grainSettings={selectedGrain?.settings || undefined}
onGrainSettingsChange={(newSettings, formValid)=>{
setCurrentCustomGrain(newSettings)
setFormValid(formValid)
}}
/>
{customGrainActions()}
</Box>
)
}
const desktopView = () => {
if(displayCustom){
return (
<Grid2 container>
<Grid2 size={7} padding={2}>
{grainObjectTable()}
</Grid2>
<Grid2 size={5} padding={2}>
{customGrainDisplay()}
</Grid2>
</Grid2>
)
}else{
return (
<Box marginTop={2}>
{supportedGrainTable()}
</Box>
)
}
}
const mobileView = () => {
return (
<Box>
{displayCustom ? grainObjectTable() : supportedGrainTable()}
</Box>
)
}
return (
<PageContainer>
<Box padding={2}>
<Typography variant="h4">
Adaptive Grains
</Typography>
<Box display="flex" marginTop={2} justifyContent="space-between">
<ButtonGroup
toggle
buttons={[
{
title: "Supported",
function: () => {
setDisplayCustom(false)
setTablePage(0)
}
},
{
title: "Custom",
function: () => {
setDisplayCustom(true)
setTablePage(0)
}
}
]}
/>
{isMobile && displayCustom &&
<IconButton sx={{padding: 0}} color="primary" onClick={()=>{
setSelectedGrain(undefined)
setCurrentCustomGrain(undefined)
setOpenNewGrainDialog(true)
}}>
<AddCircle />
</IconButton>
}
</Box>
{!isMobile ? desktopView() : mobileView()}
</Box>
{updateGrainConfirmation()}
{removeConfirmation()}
{newGrainDialog()}
</PageContainer>
)
}

View file

@ -181,7 +181,8 @@ export default function Terminals(props: Props) {
const load = useCallback(() => { const load = useCallback(() => {
if(loading) return if(loading) return
setLoading(true) setLoading(true)
terminalAPI.listTerminals(200, 0, "asc", "name", as).then(resp => { terminalAPI.listTerminals(200, 0, "asc", "name", undefined, as).then(resp => {
console.log(resp.data)
if(resp.data.terminals){ if(resp.data.terminals){
setTerminals(resp.data.terminals.map(a => Terminal.any(a))); setTerminals(resp.data.terminals.map(a => Terminal.any(a)));
} }

View file

@ -0,0 +1,137 @@
import { useHTTP } from "hooks";
import { createContext, PropsWithChildren, useContext } from "react";
import { pond } from "protobuf-ts/pond";
import { pondURL } from "./pond";
import { AxiosResponse } from "axios";
import { useGlobalState } from "providers";
import { or } from "utils";
export interface IGrainInterface {
addGrain: (
settings: pond.GrainSettings,
otherTeam?: string
) => Promise<AxiosResponse<pond.AddGrainResponse>>;
getGrain: (key: string, otherTeam?: string) => Promise<AxiosResponse<pond.GetGrainResponse>>
listGrains: (
limit: number,
offset: number,
order?: "asc" | "desc",
orderBy?: string,
search?: string,
keys?: string[],
types?: string[],
otherTeam?: string,
) => Promise<AxiosResponse<pond.ListGrainsResponse>>;
updateGrain: (key: string, settings: pond.GrainSettings, otherTeam?: string) => Promise<AxiosResponse<pond.UpdateGrainResponse>>
removeGrain: (key: string, otherTeam?: string) => Promise<AxiosResponse<pond.RemoveGrainResponse>>;
}
export const GrainAPIcontext = createContext<IGrainInterface>({} as IGrainInterface);
interface Props {}
export default function GrainProvider(props: PropsWithChildren<Props>) {
const { children } = props;
const { get, del, post, put } = useHTTP();
const [{ as }] = useGlobalState();
//add
const addGrain = (settings: pond.GrainSettings, otherTeam?: string) => {
const view = otherTeam ? otherTeam : as
if (view) {
return post<pond.AddGrainResponse>(
pondURL("/grains?&as=" + view),
settings
);
}
return post<pond.AddGrainResponse>(pondURL("/grains"), settings);
};
//get
const getGrain = (key: string, otherTeam?: string) => {
const view = otherTeam ? otherTeam : as
const url = "/grains/" + key + (view ? "?as=" + view : "")
return new Promise<AxiosResponse<pond.GetGrainResponse>>((resolve, reject)=>{
get<pond.GetGrainResponse>(pondURL(url))
.then(resp => {
if (resp.data.grain){
resp.data.grain = pond.GrainObject.fromObject(resp.data.grain)
}
resolve(resp)
})
.catch(err => {
reject(err)
})
})
}
//list
const listGrains = (
limit: number,
offset: number,
order?: "asc" | "desc",
orderBy?: string,
search?: string,
keys?: string[],
types?: string[],
otherTeam?: string
) => {
return new Promise<AxiosResponse<pond.ListGrainsResponse>>((resolve, reject)=>{
const view = otherTeam ? otherTeam : as
get<pond.ListGrainsResponse>(
pondURL(
"/grains?limit=" +
limit +
"&offset=" +
offset +
("&order=" + or(order, "asc")) +
("&by=" + or(orderBy, "key")) +
(search ? "&search=" + search : "") +
(keys ? "&keys=" + keys.join(",") : "") +
(types ? "&types=" + types.join(",") : "") +
(view ? "&as=" + view : "")
)
).then(resp => {
resp.data = pond.ListGrainsResponse.fromObject(resp.data);
return resolve(resp)
}).catch(err => {
return reject(err)
})
})
};
//update
const updateGrain = (key: string, settings: pond.GrainSettings, otherTeam?: string) => {
const view = otherTeam ? otherTeam : as
if (view) {
return put<pond.UpdateGrainResponse>(
pondURL("/grains/"+ key + "?&as=" + view),
settings
);
}
return put<pond.UpdateGrainResponse>(pondURL("/grains/" + key), settings);
}
//remove
const removeGrain = (key: string, otherTeam?: string) => {
const view = otherTeam ? otherTeam : as
if (view) {
return del<pond.RemoveGrainResponse>(pondURL("/grains/" + key + "?as=" + view));
}
return del<pond.RemoveGrainResponse>(pondURL("/grains/" + key));
};
return (
<GrainAPIcontext.Provider
value={{
addGrain,
getGrain,
listGrains,
updateGrain,
removeGrain
}}>
{children}
</GrainAPIcontext.Provider>
);
}
export const useGrainAPI = () => useContext(GrainAPIcontext);

View file

@ -38,6 +38,7 @@ import JohnDeereProvider, { useJohnDeereProxyAPI } from "./johnDeereProxyAPI";
import LibraCartProvider, { useLibraCartProxyAPI } from "./libracartProxyAPI"; import LibraCartProvider, { useLibraCartProxyAPI } from "./libracartProxyAPI";
import CNHiProvider, { useCNHiProxyAPI } from "./cnhiProxyAPI"; import CNHiProvider, { useCNHiProxyAPI } from "./cnhiProxyAPI";
import DevicePresetProvider, { useDevicePresetAPI } from "./devicePresetAPI"; import DevicePresetProvider, { useDevicePresetAPI } from "./devicePresetAPI";
import GrainProvider, { useGrainAPI } from "./grainAPI";
// import NoteProvider from "providers/noteAPI"; // import NoteProvider from "providers/noteAPI";
export const pondURL = (partial: string, demo: boolean = false): string => { export const pondURL = (partial: string, demo: boolean = false): string => {
@ -93,7 +94,9 @@ export default function PondProvider(props: PropsWithChildren<any>) {
<KeyManagerProvider> <KeyManagerProvider>
<DevicePresetProvider> <DevicePresetProvider>
<ImagekitProvider> <ImagekitProvider>
<GrainProvider>
{children} {children}
</GrainProvider>
</ImagekitProvider> </ImagekitProvider>
</DevicePresetProvider> </DevicePresetProvider>
</KeyManagerProvider> </KeyManagerProvider>
@ -173,5 +176,6 @@ export {
useJohnDeereProxyAPI, useJohnDeereProxyAPI,
useCNHiProxyAPI, useCNHiProxyAPI,
useLibraCartProxyAPI, useLibraCartProxyAPI,
useDevicePresetAPI useDevicePresetAPI,
useGrainAPI
}; };

View file

@ -166,7 +166,7 @@ export default function UserMenu() {
); );
}; };
if (user.id().length < 1) return unauthenticatedUserMenu() if (user.id()?.length < 1) return unauthenticatedUserMenu()
const hasAdmin = user.hasFeature ? user.hasFeature("admin") : false; const hasAdmin = user.hasFeature ? user.hasFeature("admin") : false;
@ -182,7 +182,7 @@ export default function UserMenu() {
<UserTeamName user={user} team={team} /> <UserTeamName user={user} team={team} />
</Box> </Box>
<Box style={{ marginLeft: theme.spacing(1) }}> <Box style={{ marginLeft: theme.spacing(1) }}>
{picture && hasTeams && team.id().length > 0 ? ( {picture && hasTeams && team.id()?.length > 0 ? (
<div> <div>
<UserAvatar <UserAvatar
user={user} user={user}
@ -238,7 +238,7 @@ export default function UserMenu() {
{hasTeams && ( {hasTeams && (
<MenuItem <MenuItem
onClick={() => { onClick={() => {
if (as.length > 1) { if (as?.length > 1) {
user.settings.useTeam = false; user.settings.useTeam = false;
dispatch({ key: "as", value: "" }); dispatch({ key: "as", value: "" });
userAPI.updateUser(user.id(), user.protobuf()).then(_resp => { userAPI.updateUser(user.id(), user.protobuf()).then(_resp => {
@ -252,13 +252,13 @@ export default function UserMenu() {
}); });
} }
}} }}
disabled={user.settings.defaultTeam.length < 1} disabled={user.settings.defaultTeam?.length < 1}
aria-label="Open Team Menu" aria-label="Open Team Menu"
dense> dense>
<ListItemIcon> <ListItemIcon>
<Checkbox <Checkbox
disabled={user.settings.defaultTeam.length < 1} disabled={user.settings.defaultTeam?.length < 1}
checked={as.length > 1} checked={as?.length > 1}
style={{ margin: 0, padding: 0 }} style={{ margin: 0, padding: 0 }}
/> />
</ListItemIcon> </ListItemIcon>