Merge branch 'field_dashboard' into staging_environment

This commit is contained in:
csawatzky 2025-10-17 09:50:30 -06:00
commit 943a6e4132
23 changed files with 1243 additions and 217 deletions

2
package-lock.json generated
View file

@ -42,7 +42,7 @@
"mui-tel-input": "^7.0.0", "mui-tel-input": "^7.0.0",
"notistack": "^3.0.1", "notistack": "^3.0.1",
"openweathermap-ts": "^1.2.10", "openweathermap-ts": "^1.2.10",
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#master", "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#field_dashboard",
"query-string": "^9.2.1", "query-string": "^9.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",

View file

@ -54,7 +54,7 @@
"mui-tel-input": "^7.0.0", "mui-tel-input": "^7.0.0",
"notistack": "^3.0.1", "notistack": "^3.0.1",
"openweathermap-ts": "^1.2.10", "openweathermap-ts": "^1.2.10",
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#master", "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#field_dashboard",
"query-string": "^9.2.1", "query-string": "^9.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",

View file

@ -124,7 +124,7 @@ export default function BinsList(props: Props) {
xs: 6, xs: 6,
sm: 4, sm: 4,
md: 3, md: 3,
lg: 2, lg: 1.5,
}} }}
key={i} key={i}
className={classes.gridListTile} className={classes.gridListTile}

View file

@ -92,7 +92,7 @@ export default function DisplayDrawer(props: Props) {
classes={{ paper: classes.drawerPaper }}> classes={{ paper: classes.drawerPaper }}>
<Box <Box
className={isMobile ? classes.drawerMobile : classes.drawerDesktop} className={isMobile ? classes.drawerMobile : classes.drawerDesktop}
style={{ width: isMobile ? "100%" : width }}> style={{ width: isMobile ? "100%" : width, height: isMobile ? drawerHeight + "vh" : 0}}>
<Box className={classes.header} style={{ width: isMobile ? "100%" : width }}> <Box className={classes.header} style={{ width: isMobile ? "100%" : width }}>
<Box display="flex" justifyContent="space-between" alignItems="center"> <Box display="flex" justifyContent="space-between" alignItems="center">
<Box> <Box>

View file

@ -285,7 +285,6 @@ export default function ComponentCard(props: Props) {
// ) : ( // ) : (
<UnitMeasurementSummary <UnitMeasurementSummary
component={component} component={component}
excludedNodes={component.settings.excludedNodes}
reading={UnitMeasurement.convertLastMeasurement(measurements)} reading={UnitMeasurement.convertLastMeasurement(measurements)}
//dense //dense
/> />

View file

@ -1,7 +1,13 @@
import { import {
Accordion,
AccordionDetails,
AccordionSummary,
Box, Box,
Button, Button,
Grid2,
IconButton,
Paper, Paper,
Stack,
Tab, Tab,
Table, Table,
TableBody, TableBody,
@ -9,16 +15,26 @@ import {
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
Tabs Tabs,
Typography
} from "@mui/material"; } from "@mui/material";
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useMobile, useSnackbar } from "hooks"; import { useMobile, useSnackbar, useUserAPI } from "hooks";
import { useGlobalState, useFieldAPI } from "providers"; import { useGlobalState, useFieldAPI, useJohnDeereProxyAPI, useCNHiProxyAPI } from "providers";
//import HarvestTable from "harvestPlan/HarvestTable"; //import HarvestTable from "harvestPlan/HarvestTable";
import { Field } from "models"; import { Field, fieldScope, teamScope } from "models";
import GrainDescriber from "grain/GrainDescriber"; import GrainDescriber from "grain/GrainDescriber";
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
import HarvestSettings from "harvestPlan/HarvestSettings"; import HarvestSettings from "harvestPlan/HarvestSettings";
import ResponsiveTable, { Column } from "common/ResponsiveTable";
import FieldMinimap from "./Fieldminimap";
import FieldActions from "./FieldActions";
import FieldSettings from "./FieldSettings";
import { ArrowForward, ExpandMore, Settings } from "@mui/icons-material";
import { cloneDeep } from "lodash";
import ShareAllFields from "./ShareAllFields";
import EventBlocker from "common/EventBlocker";
import { useNavigate } from "react-router-dom";
interface TabPanelProps { interface TabPanelProps {
children?: React.ReactNode; children?: React.ReactNode;
@ -26,128 +42,244 @@ interface TabPanelProps {
value: any; value: any;
} }
function TabPanelMine(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
aria-labelledby={`simple-tab-${index}`}
style={{ height: "94%" }}
{...other}>
{value === index && <React.Fragment>{children}</React.Fragment>}
</div>
);
}
export default function FieldList() { export default function FieldList() {
const [{ as }] = useGlobalState(); const [{ as }] = useGlobalState();
const isMobile = useMobile(); const isMobile = useMobile();
const fieldAPI = useFieldAPI(); const fieldAPI = useFieldAPI();
const jdAPI = useJohnDeereProxyAPI();
const cnhAPI = useCNHiProxyAPI();
const { openSnack } = useSnackbar(); const { openSnack } = useSnackbar();
const [fields, setFields] = useState<Field[]>([]); const [tabValue, setTabValue] = React.useState(0);
const [value, setValue] = React.useState(0);
const [openHarvestSettings, setOpenHarvestSettings] = useState(false); const [openHarvestSettings, setOpenHarvestSettings] = useState(false);
const [fieldForPlan, setFieldForPlan] = useState(Field.create()); // const [fieldForPlan, setFieldForPlan] = useState(Field.create());
const [fields, setFields] = useState<Field[]>([]);
const [total, setTotal] = useState(0)
const [rowsPerPage, setRowsPerPage] = useState(5)
const [page, setPage] = useState(0)
const [selectedField, setSelectedField] = useState<Field>()
const [openFieldSettings, setOpenFieldSettings] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
const navigate = useNavigate();
const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
setValue(newValue); setTabValue(newValue);
setPage(0)
}; };
const loadFields = useCallback(() => { const loadAdaptiveFields = useCallback(() => {
fieldAPI fieldAPI
.listFields(500, 0, "asc", "fieldName", undefined, as) .listFields(rowsPerPage, page*rowsPerPage, "asc", "fieldName", undefined, as)
.then(resp => { .then(resp => {
setFields(resp.data.fields.map(f => Field.any(f))); setFields(resp.data.fields.map(f => Field.create(f)));
// resp.data.fields.forEach(f => { setTotal(resp.data.total)
// if (f.settings) {
// fieldAPI.updateField(f.settings.key, f.settings);
// }
// });
}) })
.catch(err => { .catch(err => {
openSnack("Failed to load Field Mapping"); openSnack("Failed to load Fields");
}); });
}, [fieldAPI, as, openSnack]); }, [fieldAPI, as, openSnack, page, rowsPerPage]);
const loadJDFields = useCallback(() => {
jdAPI.listFields(rowsPerPage, page*rowsPerPage, as)
.then(resp => {
resp.data.fields ? setFields(resp.data.fields.map(f => Field.create(f))) : setFields([])
}).catch(err => {
openSnack("Failed to load John Deere Fields");
})
},[jdAPI, as, openSnack, page, rowsPerPage])
const loadCNHFields = useCallback(() => {
cnhAPI.listFields(rowsPerPage, page*rowsPerPage, as)
.then(resp => {
resp.data.fields ? setFields(resp.data.fields.map(f => Field.create(f))) : setFields([])
}).catch(err => {
openSnack("Failed to load John Deere Fields");
})
},[cnhAPI, as, openSnack, page, rowsPerPage])
useEffect(() => { useEffect(() => {
loadFields(); //based on the current tab load the correct fields
}, [loadFields]); switch(tabValue){
case 0:
loadAdaptiveFields();
break;
case 1:
loadJDFields();
break;
case 2:
loadCNHFields();
break;
}
}, [loadAdaptiveFields, loadJDFields, loadCNHFields, tabValue]);
const calcTotalAcres = () => { const goTo = (field: Field) => {
let totalAcres = 0; let path = field.key();
fields.forEach(field => { navigate(path, { state: {field: field, permissions: field.permissions} });
totalAcres += field.acres(); }
});
totalAcres = Math.round(totalAcres);
return totalAcres;
};
const listFieldsInfo = fields.map((field, index) => ( const fieldActions = (field: Field) => {
<TableRow key={index}> return (
<TableCell>{field.fieldName()}</TableCell> <EventBlocker>
<TableCell>{field.landLoc()}</TableCell> <IconButton
<TableCell>
{field.crop() === pond.Grain.GRAIN_CUSTOM
? field.customType()
: GrainDescriber(field.crop()).name}
</TableCell>
<TableCell>{field.calculateAcres()}</TableCell>
<TableCell>
{field.permissions.includes(pond.Permission.PERMISSION_WRITE) && (
<Button
variant="contained"
color="primary"
onClick={() => { onClick={() => {
setFieldForPlan(field); setSelectedField(field)
setOpenHarvestSettings(true); setOpenFieldSettings(true)
}}> }}><Settings /></IconButton>
New Crop Plan <FieldActions field={field} permissions={field.permissions} refreshCallback={()=>{}}/>
</Button> {isMobile && <IconButton onClick={() => {goTo(field)}}><ArrowForward /></IconButton>}
)} </EventBlocker>
</TableCell> )
</TableRow> }
));
const columns: Column<Field>[] = [
{
title: "View",
render: row => {
return (
<Box margin={1} height={200} width={200}>
<FieldMinimap field={row} squared borderSpacing={0.001}/>
</Box>
)
}
},
{
title: "Field Name",
render: row => {
return <Typography paddingLeft={2}>{row.name()}</Typography>
}
},
{
title: "Current Crop",
render: row => {
return <Typography paddingLeft={2}>{GrainDescriber(row.crop()).name}</Typography>
}
},
{
title: "Acres",
render: row => {
return <Typography paddingLeft={2}>{row.acres()}</Typography>
}
},
{
title: "Actions",
hidden: tabValue > 0,
render: row => fieldActions(row)
}
]
const fieldTable = (fields: Field[]) => {
return (
<ResponsiveTable<Field>
columns={columns}
rows={fields}
onRowClick={(row) => {
!isMobile && goTo(row)
}}
setPage={(newPage)=>{
setPage(newPage)
}}
handleRowsPerPageChange={(e)=>{setRowsPerPage(e.target.value)}}
page={page}
pageSize={rowsPerPage}
total={total}
resizeable
loadMore={() => {
let currentFields = cloneDeep(fields)
switch(tabValue){
case 0:
fieldAPI.listFields(rowsPerPage, currentFields.length, "asc", "fieldName", undefined, as).then(resp => {
setFields(currentFields.concat(resp.data.fields.map(f => Field.create(f))))
}).catch(err => {
openSnack("Failed to load more fields")
})
break;
case 1:
jdAPI.listFields(rowsPerPage, currentFields.length, as).then(resp => {
resp.data.fields ? setFields(currentFields.concat(resp.data.fields.map(f => Field.create(f)))) : setFields([])
}).catch(err => {
openSnack("Failed to load more fields")
})
break;
case 2:
cnhAPI.listFields(rowsPerPage, currentFields.length, as).then(resp => {
resp.data.fields ? setFields(currentFields.concat(resp.data.fields.map(f => Field.create(f)))) : setFields([])
}).catch(err => {
openSnack("Failed to load more fields")
})
break;
}
}}
renderMobile={(row, index) => {
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Grid2 alignItems="center" width="100%" container direction={"row"} justifyContent="space-between" wrap="nowrap">
<Grid2>
<Typography textAlign="center">
{row.name()}
</Typography>
</Grid2>
<Grid2>
{fieldActions(row)}
</Grid2>
</Grid2>
</AccordionSummary>
<AccordionDetails>
<Grid2 alignItems="center" width="100%" container direction={"row"} justifyContent="space-between" wrap="nowrap">
<Grid2 size={6}>
<Box height={150} width="100%">
<FieldMinimap field={row} borderSpacing={0.001} squared />
</Box>
</Grid2>
<Grid2 size={6}>
<Stack spacing={2}>
<Typography textAlign="center">{row.grainName()}</Typography>
<Typography textAlign="center">{row.acres()} Acres</Typography>
</Stack>
<Button variant="contained" color="primary">Go</Button>
</Grid2>
</Grid2>
</AccordionDetails>
</Accordion>
)
}}
/>
)
}
return ( return (
<Box> <Box>
<Button
onClick={()=>{setShareOpen(true)}}
variant="contained"
color="primary">
Share All
</Button>
<Tabs <Tabs
value={value} value={tabValue}
onChange={handleChange} onChange={handleChange}
TabIndicatorProps={{ style: { background: "rgba(255,255,0,255)" } }}> TabIndicatorProps={{ style: { background: "rgba(255,255,0,255)" } }}>
<Tab label="Field Overview" /> <Tab label="Adaptive" />
{/* {!isMobile && <Tab label="Current Harvest Plans" />} <Tab label="John Deere" />
{!isMobile && <Tab label="Plan History" />} */} <Tab label="Case New Holland" />
</Tabs> </Tabs>
<TabPanelMine index={0} value={value}> {fieldTable(fields)}
<TableContainer component={Paper}> {/* <HarvestSettings
<Table style={{ minWidth: 1000 }}>
<TableHead>
<TableRow>
<TableCell>Field Name</TableCell>
<TableCell>Land Location</TableCell>
<TableCell>Main Crop Type</TableCell>
<TableCell>Acres ({calcTotalAcres()} Total)</TableCell>
<TableCell>Create New Plan</TableCell>
</TableRow>
</TableHead>
<TableBody>{listFieldsInfo}</TableBody>
</Table>
</TableContainer>
</TabPanelMine>
{/* need to re-factor the harvest table file before restoring these functions */}
{/* <TabPanelMine index={1} value={value}>
<HarvestTable fields={fields} display="current" />
</TabPanelMine>
<TabPanelMine index={2} value={value}>
<HarvestTable fields={fields} display="history" />
</TabPanelMine> */}
<HarvestSettings
open={openHarvestSettings} open={openHarvestSettings}
close={() => setOpenHarvestSettings(false)} close={() => setOpenHarvestSettings(false)}
field={fieldForPlan} field={fieldForPlan}
/> */}
<FieldSettings
selectedField={selectedField}
removeField={() => {
//will need to re-load the fields after one is deleted
loadAdaptiveFields()
}}
open={openFieldSettings}
onClose={() => {
setOpenFieldSettings(false);
}}
/> />
<ShareAllFields open={shareOpen} close={() => {setShareOpen(false)}}/>
</Box> </Box>
); );
} }

View file

@ -152,9 +152,7 @@ export default function FieldSettings(props: Props) {
const fieldInformation = () => { const fieldInformation = () => {
return ( return (
<Box> <React.Fragment>
<Box>
<DialogTitle>Field Information</DialogTitle>
<TextField <TextField
value={fieldName} value={fieldName}
margin="normal" margin="normal"
@ -258,7 +256,14 @@ export default function FieldSettings(props: Props) {
Field Colour Field Colour
<ColourPicker onChange={color => setFeatColor(color)} /> <ColourPicker onChange={color => setFeatColor(color)} />
</Box> </Box>
</Box> </React.Fragment>
);
};
return (
<ResponsiveDialog fullWidth open={props.open} onClose={onClose}>
<DialogTitle>Field Information</DialogTitle>
<DialogContent>{fieldInformation()}</DialogContent>
<DialogActions> <DialogActions>
<Grid container direction="row"> <Grid container direction="row">
<Grid> <Grid>
@ -282,13 +287,6 @@ export default function FieldSettings(props: Props) {
</Grid> </Grid>
</Grid> </Grid>
</DialogActions> </DialogActions>
</Box>
);
};
return (
<ResponsiveDialog fullWidth fullScreen={false} open={props.open} onClose={onClose}>
<DialogContent>{fieldInformation()}</DialogContent>
</ResponsiveDialog> </ResponsiveDialog>
); );
} }

View file

@ -0,0 +1,61 @@
import { CircularProgress } from "@mui/material";
import { Field } from "models";
import { useEffect, useState } from "react";
import Map from "react-map-gl/mapbox";
import 'mapbox-gl/dist/mapbox-gl.css';
import GeoMapLayer from "maps/mapLayers/geoMapLayer";
import { Feature, FeatureCollection } from "geojson";
import { GeometryMapping } from "models/GeometryMapping";
interface Props {
field: Field
squared?: boolean
borderSpacing?: number
}
export default function FieldMinimap(props: Props) {
const { field, squared, borderSpacing } = props
const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN;
const [geoCollection, setGeoCollection] = useState<FeatureCollection>();
useEffect(()=>{
let fieldData = field.settings.fieldGeoData
if(fieldData){
let fieldFeature = GeometryMapping.geoJSON(fieldData.geoShape, fieldData.shapes, fieldData.holes) as Feature;
fieldFeature.id = fieldData.objectKey;
fieldFeature.properties = {
title: fieldData.title,
objectKey: fieldData.objectKey,
fill: fieldData.colour,
lineWidth: fieldData.geoShape === "LineString" ? 5 : 2,
origin: fieldData.origin
};
let collection: FeatureCollection = {
type: "FeatureCollection",
features: [fieldFeature]
};
setGeoCollection(collection);
}
},[field])
return geoCollection ?
(
<Map
zoom={1}
maxBounds={
field.fieldBounds(borderSpacing, squared)
}
reuseMaps
style={{width: "100%", height: "100%"}}
mapboxAccessToken={MAPBOX_TOKEN}
mapStyle="mapbox://styles/mapbox/satellite-streets-v11"
>
<GeoMapLayer
objectCollection={geoCollection}
/>
</Map>
)
:
(<CircularProgress />)
}

View file

@ -0,0 +1,201 @@
import { Box, Button, Checkbox, DialogActions, DialogContent, DialogTitle, FormControl, FormControlLabel, FormGroup, FormLabel, Grid2, Stack, Switch, TextField } from "@mui/material";
import ResponsiveDialog from "common/ResponsiveDialog";
import { cloneDeep } from "lodash";
import { pond } from "protobuf-ts/pond";
import { useFieldAPI, useSnackbar } from "providers";
import React, { useEffect, useState } from "react";
import TeamSearch from "teams/TeamSearch";
interface Props {
open: boolean
close: () => void
}
export default function ShareAllFields(props: Props) {
const {open, close} = props
const [teamShare, setTeamShare] = useState(false)
const fieldAPI = useFieldAPI();
const { openSnack } = useSnackbar()
const [team, setTeam] = useState<string>("")
const [user, setUser] = useState<string>("")
const [sharedPermissions, setSharedPermissions] = useState<pond.Permission[]>([
pond.Permission.PERMISSION_READ
])
const share = () => {
if(teamShare){
//if sharing to team use shareAllByKey
fieldAPI.shareAllByKey(team, sharedPermissions)
.then(resp => {
openSnack("Shared all fields to team")
}).catch(err => {
openSnack("There was a problem sharing the fields")
})
}else{
//if sharing to user use shareAll
fieldAPI.shareAll(user, sharedPermissions)
.then(resp => {
openSnack("Shared all fields to user")
}).catch(err => {
openSnack("There was a problem sharing the fields")
})
}
}
const target = () => {
// toggle for whether sharing to user or team
return (
<React.Fragment>
<Grid2 container alignItems="center">
<Grid2>User</Grid2>
<Grid2>
<Switch
color="default"
value={teamShare}
checked={teamShare}
onChange={(_, checked) => {
setTeamShare(checked)
}}
name="storage"
/>
</Grid2>
<Grid2>Team</Grid2>
</Grid2>
{teamShare ?
<Box>
<TeamSearch label="Team" setTeamCallback={(teamKey) => {setTeam(teamKey)}}/>
</Box> :
<Box>
<TextField
label="User Email"
fullWidth
value={user}
onChange={(e) => {
setUser(e.target.value)
}}
/>
</Box>}
</React.Fragment>
)
// if sharing to user have a text field
}
const changePermissions = (checked: boolean, permission: pond.Permission) => {
let currentPerms = cloneDeep(sharedPermissions)
if(checked){
//if the permissions does not include the permission add it
if(!sharedPermissions.includes(permission)) currentPerms.push(permission)
}else{
//if the permissions includes the permission remove it
if(sharedPermissions.includes(permission)) currentPerms.splice(currentPerms.indexOf(permission), 1)
}
setSharedPermissions(currentPerms)
}
const permissions = () => {
// the checkboxes of permissions
return (
<React.Fragment>
<FormControl sx={{marginTop: 2}} component="fieldset" fullWidth variant="outlined">
<FormLabel component="legend">Permissions</FormLabel>
<FormGroup>
<FormControlLabel
control={
<Checkbox
disabled={sharedPermissions.includes(pond.Permission.PERMISSION_READ)}
checked={sharedPermissions.includes(pond.Permission.PERMISSION_READ)}
onChange={(_, checked) => {changePermissions(checked, pond.Permission.PERMISSION_READ)}}
value={pond.Permission.PERMISSION_READ as pond.Permission}
/>
}
label="View"
labelPlacement="end"
/>
<FormControlLabel
control={
<Checkbox
checked={sharedPermissions.includes(pond.Permission.PERMISSION_WRITE)}
onChange={(_, checked) => {changePermissions(checked, pond.Permission.PERMISSION_WRITE)}}
value={pond.Permission.PERMISSION_WRITE as pond.Permission}
/>
}
label="Edit"
labelPlacement="end"
/>
<FormControlLabel
control={
<Checkbox
checked={sharedPermissions.includes(pond.Permission.PERMISSION_SHARE)}
onChange={(_, checked) => {changePermissions(checked, pond.Permission.PERMISSION_SHARE)}}
value={pond.Permission.PERMISSION_SHARE as pond.Permission}
/>
}
label="Share"
labelPlacement="end"
/>
<FormControlLabel
control={
<Checkbox
checked={sharedPermissions.includes(pond.Permission.PERMISSION_USERS)}
onChange={(_, checked) => {changePermissions(checked, pond.Permission.PERMISSION_USERS)}}
value={pond.Permission.PERMISSION_USERS as pond.Permission}
/>
}
label="Users"
labelPlacement="end"
/>
<FormControlLabel
control={
<Checkbox
checked={sharedPermissions.includes(pond.Permission.PERMISSION_FILE_MANAGEMENT)}
onChange={(_, checked) => {changePermissions(checked, pond.Permission.PERMISSION_FILE_MANAGEMENT)}}
value={pond.Permission.PERMISSION_FILE_MANAGEMENT as pond.Permission}
/>
}
label="Files"
labelPlacement="end"
/>
{/* billing */}
<FormControlLabel
control={
<Checkbox
checked={sharedPermissions.includes(pond.Permission.PERMISSION_BILLING)}
onChange={(_, checked) => {changePermissions(checked, pond.Permission.PERMISSION_BILLING)}}
value={pond.Permission.PERMISSION_BILLING as pond.Permission}
/>
}
label="Billing"
labelPlacement="end"
/>
</FormGroup>
</FormControl>
</React.Fragment>
)
}
const actions = () => {
//the buttons to shaare or cancel
return (
<React.Fragment>
<Button onClick={close}>Close</Button>
<Button onClick={share} variant="contained" color="primary">Share</Button>
</React.Fragment>
)
}
return (
<ResponsiveDialog open={open} onClose={close}>
<DialogTitle>Share All Fields</DialogTitle>
<DialogContent>
<Box minWidth={300}>
{target()}
{permissions()}
</Box>
</DialogContent>
<DialogActions>
{actions()}
</DialogActions>
</ResponsiveDialog>
)
}

View file

@ -7,16 +7,17 @@ import {
TextField TextField
} from "@mui/material"; } from "@mui/material";
import ResponsiveDialog from "common/ResponsiveDialog"; import ResponsiveDialog from "common/ResponsiveDialog";
import { Option } from "common/SearchSelect";
import { Field, HarvestPlan, Task } from "models"; import { Field, HarvestPlan, Task } from "models";
import moment from "moment"; import moment from "moment";
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
import { useGlobalState, useHarvestPlanAPI, useTaskAPI } from "providers"; import { useFieldAPI, useGlobalState, useHarvestPlanAPI, useTaskAPI } from "providers";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
interface Props { interface Props {
open: boolean; open: boolean;
close: () => void; close: () => void;
fields: Field[]; fields?: Field[];
plan: HarvestPlan; plan: HarvestPlan;
} }
@ -29,10 +30,14 @@ export default function DuplicateHarvestPlan(props: Props) {
const harvestPlanAPI = useHarvestPlanAPI(); const harvestPlanAPI = useHarvestPlanAPI();
const taskAPI = useTaskAPI(); const taskAPI = useTaskAPI();
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [fieldOptions, setFieldOptions] = useState<Field[]>([])
const fieldAPI = useFieldAPI();
const loadTasks = useCallback(() => { const loadTasks = useCallback(() => {
taskAPI.listTasks(50, 0, "asc", "start", plan.key(), undefined).then(resp => { taskAPI.listTasks(50, 0, "asc", "start", plan.key(), undefined).then(resp => {
if(resp.data.tasks){
setPlanTasks(resp.data.tasks.map(t => Task.any(t))); setPlanTasks(resp.data.tasks.map(t => Task.any(t)));
}
setReady(true); setReady(true);
}); });
}, [taskAPI, plan]); }, [taskAPI, plan]);
@ -41,9 +46,25 @@ export default function DuplicateHarvestPlan(props: Props) {
loadTasks(); loadTasks();
}, [loadTasks]); }, [loadTasks]);
useEffect(()=>{
if(fields){
//if fields were passed in use those
setFieldOptions(fields)
}else{
//otherwise load them
fieldAPI.listFields(50, 0, "asc", "fieldName").then(resp => {
setFieldOptions(resp.data.fields.map(f => Field.create(f)))
}).catch(err => {
})
}
},[fields])
//this should load the fields here
const duplicate = () => { const duplicate = () => {
let planSettings = HarvestPlan.clone(plan).settings; let planSettings = HarvestPlan.clone(plan).settings;
planSettings.field = fields[newFieldIndex].key(); planSettings.field = fieldOptions[newFieldIndex].key();
planSettings.createDate = moment.now(); planSettings.createDate = moment.now();
if (title !== "") { if (title !== "") {
planSettings.title = title; planSettings.title = title;
@ -69,7 +90,7 @@ export default function DuplicateHarvestPlan(props: Props) {
value={newFieldIndex} value={newFieldIndex}
onChange={e => setNewFieldIndex(+e.target.value)} onChange={e => setNewFieldIndex(+e.target.value)}
color="primary"> color="primary">
{fields.map((option, i) => ( {fieldOptions.map((option, i) => (
<MenuItem key={option.key()} value={i}> <MenuItem key={option.key()} value={i}>
{option.fieldName()} {option.fieldName()}
</MenuItem> </MenuItem>
@ -98,8 +119,8 @@ export default function DuplicateHarvestPlan(props: Props) {
color="primary" color="primary"
disabled={ disabled={
!ready || !ready ||
(fields[newFieldIndex] && (fieldOptions[newFieldIndex] &&
!fields[newFieldIndex].permissions.includes(pond.Permission.PERMISSION_WRITE)) !fieldOptions[newFieldIndex].permissions.includes(pond.Permission.PERMISSION_WRITE))
}> }>
Duplicate Duplicate
</Button> </Button>

View file

@ -13,7 +13,7 @@ import ShareObjectIcon from "@mui/icons-material/Share";
import ObjectUsersIcon from "@mui/icons-material/AccountCircle"; import ObjectUsersIcon from "@mui/icons-material/AccountCircle";
import ObjectTeamsIcon from "@mui/icons-material/SupervisedUserCircle"; import ObjectTeamsIcon from "@mui/icons-material/SupervisedUserCircle";
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import ObjectUsers from "user/ObjectUsers"; import ObjectUsers from "user/ObjectUsers";
import ShareObject from "user/ShareObject"; import ShareObject from "user/ShareObject";
import { isOffline } from "utils/environment"; import { isOffline } from "utils/environment";
@ -43,7 +43,7 @@ const useStyles = makeStyles((theme: Theme) => ({
interface Props { interface Props {
plan: HarvestPlan; plan: HarvestPlan;
planField: Field; planField: Field;
fieldList: Field[]; fieldList?: Field[];
permissions: pond.Permission[]; permissions: pond.Permission[];
refreshCallback: (updatedPlan?: HarvestPlan) => void; refreshCallback: (updatedPlan?: HarvestPlan) => void;
hideNew?: boolean; hideNew?: boolean;
@ -215,7 +215,7 @@ export default function HarvestPlanActions(props: Props) {
setUpdatePlan(true); setUpdatePlan(true);
setOpenState({ ...openState, settings: true }); setOpenState({ ...openState, settings: true });
}}> }}>
<Edit /> Edit or Add Activity <Edit /> Activities
</Button> </Button>
)} )}
<IconButton <IconButton

View file

@ -10,7 +10,7 @@ import {
import { Notes } from "@mui/icons-material"; import { Notes } from "@mui/icons-material";
import GrainDescriber from "grain/GrainDescriber"; import GrainDescriber from "grain/GrainDescriber";
import { Field, HarvestPlan } from "models"; import { Field, HarvestPlan } from "models";
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { getThemeType } from "theme"; import { getThemeType } from "theme";
import Chat from "chat/Chat"; import Chat from "chat/Chat";
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
@ -23,7 +23,7 @@ interface Props {
permissions: pond.Permission[]; permissions: pond.Permission[];
planField: Field; planField: Field;
loading: boolean; loading: boolean;
fieldList: Field[]; fieldList?: Field[];
changePlan: (updatedPlan?: HarvestPlan) => void; changePlan: (updatedPlan?: HarvestPlan) => void;
} }
@ -175,7 +175,7 @@ export default function HarvestPlanDisplay(props: Props) {
<Typography <Typography
variant="h5" variant="h5"
style={{ fontWeight: 700, marginTop: "auto", marginBottom: "auto" }}> style={{ fontWeight: 700, marginTop: "auto", marginBottom: "auto" }}>
Crop Plan{plan.key() !== "" ? " - " + plan.settings.title : " - None Found"} {plan.key() !== "" ? plan.settings.title : "None Found"}
</Typography> </Typography>
{!isMobile && permissions.includes(pond.Permission.PERMISSION_WRITE) && ( {!isMobile && permissions.includes(pond.Permission.PERMISSION_WRITE) && (
<HarvestPlanActions <HarvestPlanActions

View file

@ -0,0 +1,202 @@
import { ExpandMore } from "@mui/icons-material"
import { Accordion, AccordionDetails, AccordionSummary, Box, Theme, Typography } from "@mui/material"
import { makeStyles } from "@mui/styles"
import ResponsiveTable, { Column } from "common/ResponsiveTable"
import GrainDescriber from "grain/GrainDescriber"
import { cloneDeep } from "lodash"
import { Field, HarvestPlan } from "models"
import { useHarvestPlanAPI } from "providers"
import { useCallback, useEffect, useState } from "react"
import { getThemeType } from "theme"
interface Props {
field: Field
}
const useStyles = makeStyles((theme: Theme) => ({
dark: {
backgroundColor: getThemeType() === "light" ? "rgb(245, 245, 245)" : "rgb(50, 50, 50)",
padding: 5
},
light: {
backgroundColor: getThemeType() === "light" ? "rgb(235, 235, 235)" : "rgb(60, 60, 60)",
padding: 5
}
}));
export default function HarvestPlanTable(props: Props){
const {field} = props
const harvestAPI = useHarvestPlanAPI()
const [page, setPage] = useState(0)
const [pageSize, setPageSize] = useState(10)
const [loadedPlans, setLoadedPlans] = useState<HarvestPlan[]>([])
const [total, setTotal] = useState(0)
const classes = useStyles()
useEffect(()=>{
harvestAPI.listHarvestPlans(pageSize, page*pageSize, "desc", "harvestYear", field.key()).then(resp => {
if(resp.data.harvestPlan){
setLoadedPlans(resp.data.harvestPlan.map(hp => HarvestPlan.create(hp)))
}
setTotal(resp.data.total)
})
},[page, pageSize, field])
const columns: Column<HarvestPlan>[] = [
//these columns would be the ones on by default
//field - hideable
{
title: "Field",
hidden: field !== undefined,
render: (row) => <Typography align="center">{row.field()}</Typography>
},
//title
{
title: "Plan",
render: (row) => <Typography align="center">{row.settings.title}</Typography>
},
//year
{
title: "Year",
render: (row) => <Typography align="center">{row.harvestYear()}</Typography>
},
//grain - crop_type
{
title: "Grain",
render: (row) => <Typography align="center">{GrainDescriber(row.cropType()).name}</Typography>
},
//variant - grain_type
{
title: "Variant",
render: (row) => <Typography align="center">{row.grainType()}</Typography>
},
//yield target
{
title: "Yield Target (bu)",
render: (row) => <Typography align="right">{row.yieldTarget()}</Typography>
},
//actual yield
{
title: "Actual Yield (bu)",
render: (row) => <Typography align="right">{row.actualYield()}</Typography>
},
//bushel price
{
title: "Bushel Price",
render: (row) => <Typography align="right">{row.bushelPrice()}</Typography>
},
//material cost
{
title: "Material Cost",
render: (row) => <Typography align="right">{row.totalMaterialCost()}</Typography>
},
//equipment cost
{
title: "Equipment Cost",
render: (row) => <Typography align="right">{row.totalEquipmentCost()}</Typography>
}
//these columns could be optional (disabled by default but turned on later?)
//pre-seed materials
//pre-seed equpment
//seed materials
//seed equpment
//post-seed materials
//post-seed equpment
//harvest materials
//harvest equpment
//fall materials
//fall equpment
]
return (
<ResponsiveTable<HarvestPlan>
columns={columns}
title={"Harvest Plans"}
resizeable
page={page}
pageSize={pageSize}
rows={loadedPlans}
total={total}
handleRowsPerPageChange={(e)=>{
setPageSize(e.target.value)
}}
setPage={(page)=>{
setPage(page)
}}
loadMore={() => {
harvestAPI.listHarvestPlans(pageSize, loadedPlans.length, "desc", "harvestYear", field.key()).then(resp => {
let currentPlans = cloneDeep(loadedPlans)
let newPlans: HarvestPlan[] = []
if(resp.data.harvestPlan){
newPlans = resp.data.harvestPlan.map(hp => HarvestPlan.create(hp))
}
setLoadedPlans(currentPlans.concat(newPlans))
})
}}
renderMobile={(row, index) => {
// since these are more about previous harvest plans i am only showing the final tallys rather than the breakdowns
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Typography sx={{fontWeight: 650}}>
{row.settings.title} - {row.harvestYear()}
</Typography>
</AccordionSummary>
<AccordionDetails>
{/* grain */}
<Box className={classes.light} style={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="subtitle1">Grain:</Typography>
<Typography variant="subtitle1">{GrainDescriber(row.cropType()).name}</Typography>
</Box>
{/* variant */}
<Box className={classes.dark} style={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="subtitle1">Variant:</Typography>
<Typography variant="subtitle1">{row.grainType()}</Typography>
</Box>
{/* yield target */}
{/* <Box className={classes.dark} style={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="subtitle1">Yield Target:</Typography>
<Typography variant="subtitle1">{row.yieldTarget()}bu</Typography>
</Box> */}
{/* actual yield */}
<Box className={classes.light} style={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="subtitle1">Actual Yield:</Typography>
<Typography variant="subtitle1">{row.actualYield()}bu</Typography>
</Box>
{/* bushel price */}
<Box className={classes.dark} style={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="subtitle1">Bushel Price:</Typography>
<Typography variant="subtitle1">${row.bushelPrice()}</Typography>
</Box>
{/* material cost */}
<Box className={classes.light} style={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="subtitle1">Material Cost:</Typography>
<Typography variant="subtitle1">${row.totalMaterialCost() * field.acres()}</Typography>
</Box>
{/* equipment cost */}
<Box className={classes.dark} style={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="subtitle1">Equipment Cost:</Typography>
<Typography variant="subtitle1">${row.totalEquipmentCost() * field.acres()}</Typography>
</Box>
{/* pre-seed materials */}
{/* pre-seed equpment */}
{/* seed materials */}
{/* seed equpment */}
{/* post-seed materials */}
{/* post-seed equpment */}
{/* harvest materials */}
{/* harvest equpment */}
{/* fall materials */}
{/* fall equpment */}
{/* total cost */}
<Box className={classes.light} style={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="subtitle1">Total Cost:</Typography>
<Typography variant="subtitle1">${(row.totalEquipmentCost() + row.totalMaterialCost()) * field.acres()}</Typography>
</Box>
</AccordionDetails>
</Accordion>
)
}}
/>
)
}

View file

@ -273,7 +273,7 @@ export default function FieldDrawer(props: Props) {
<Weather longitude={field.center().longitude} latitude={field.center().latitude} /> <Weather longitude={field.center().longitude} latitude={field.center().latitude} />
</TabPanelMine> </TabPanelMine>
<TabPanelMine value={value} index={2}> <TabPanelMine value={value} index={2}>
<TaskViewer drawerView objectKey={field.key()} loadKeys={taskLoadKeys} /> <TaskViewer drawerView objectKey={field.key()} loadKeys={taskLoadKeys} overlayButton />
</TabPanelMine> </TabPanelMine>
</React.Fragment> </React.Fragment>
); );

View file

@ -6,7 +6,7 @@ interface Props {
objectCollection: FeatureCollection; objectCollection: FeatureCollection;
measurementCollection?: FeatureCollection; measurementCollection?: FeatureCollection;
showTitle?: boolean; showTitle?: boolean;
setInteractiveLayers: (layers: string[]) => void; setInteractiveLayers?: (layers: string[]) => void;
} }
export default function GeoMapLayer(props: Props) { export default function GeoMapLayer(props: Props) {
@ -18,7 +18,9 @@ export default function GeoMapLayer(props: Props) {
// }, [measurementCollection, objectCollection]); // }, [measurementCollection, objectCollection]);
useEffect(() => { useEffect(() => {
if(setInteractiveLayers){
setInteractiveLayers(["shapefill", "shapeborder", "measurementBorder"]); setInteractiveLayers(["shapefill", "shapeborder", "measurementBorder"]);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
@ -60,6 +62,7 @@ export default function GeoMapLayer(props: Props) {
/> />
)} )}
</Source> </Source>
{measurementCollection &&
<Source type="geojson" data={measurementCollection} id={"measurements"}> <Source type="geojson" data={measurementCollection} id={"measurements"}>
<Layer <Layer
id="measurementBorder" id="measurementBorder"
@ -90,6 +93,7 @@ export default function GeoMapLayer(props: Props) {
}} }}
/> />
</Source> </Source>
}
</React.Fragment> </React.Fragment>
); );
} }

View file

@ -5,6 +5,7 @@ import area from "@turf/area";
import { Feature } from "geojson"; import { Feature } from "geojson";
import { GeometryMapping } from "./GeometryMapping"; import { GeometryMapping } from "./GeometryMapping";
import GrainDescriber from "grain/GrainDescriber"; import GrainDescriber from "grain/GrainDescriber";
import { LngLat, LngLatBounds } from "mapbox-gl";
export class Field { export class Field {
public settings: pond.FieldSettings = pond.FieldSettings.create(); public settings: pond.FieldSettings = pond.FieldSettings.create();
@ -14,9 +15,11 @@ export class Field {
public static create(pf?: pond.Field): Field { public static create(pf?: pond.Field): Field {
let my = new Field(); let my = new Field();
if (pf) { if (pf) {
my.settings = pond.FieldSettings.fromObject(cloneDeep(or(pf.settings, {}))); //the from object is required here to deal with a protobuf quirk where enums would be seen as strings at runtime, but the code sees them as the actual value
my.status = pond.FieldStatus.fromObject(cloneDeep(or(pf.status, {}))); let field = pond.Field.fromObject(cloneDeep(or(pf, pond.Field.create())))
my.permissions = pf.fieldPermissions; my.settings = field.settings ? field.settings : pond.FieldSettings.create()
my.status = field.status ? field.status : pond.FieldStatus.create()
my.permissions = field.fieldPermissions
} }
return my; return my;
} }
@ -152,6 +155,60 @@ export class Field {
return coords; return coords;
} }
/**
* Returns a LngLatBounds object containing the southwest and northeast corners of a box to contain the field, spacing can also be provided
* to pad the sides of the bounding area
* @param spacing number(optional) - an optional paramater that will expand the bounding area by the given long lat amount
* @param squared boolean(optional) - when true will adjust the bounds by extending the narrower edge to match the wider edge
* @returns LngLatBounds - an object containing the southwest and northeast coordinates of a bounding box
*/
public fieldBounds(spacing?: number, squared?: boolean): LngLatBounds {
let minLong = 0;
let minLat = 0;
let maxLong = 0;
let maxLat = 0;
if (this.settings.fieldGeoData) {
this.settings.fieldGeoData.shapes.forEach(shape => {
shape.points.forEach(pair => {
if (pair.longitude < minLong || minLong === 0) minLong = pair.longitude;
if (pair.longitude > maxLong || maxLong === 0) maxLong = pair.longitude;
if (pair.latitude < minLat || minLat === 0) minLat = pair.latitude;
if (pair.latitude > maxLat || maxLat === 0) maxLat = pair.latitude;
});
});
}
// let southWest = [minLong,minLat]
// let northEast = [maxLong,maxLat]
if (squared){
//make sure the bounds are squared so as to fit the entire field in the map
//get the diff between the longs and the lats
let longDiff = maxLong - minLong
let latDiff = maxLat - minLat
//find which is narrower
if(longDiff > latDiff){
//if the longitude is the narrow bound apply half of the diff between the diffs to the min and max lats
let halfDiff = (longDiff - latDiff)/2
minLat = minLat - halfDiff
maxLat = maxLat + halfDiff
} else {
//if the longitude is not the narrow bound apply half of the diff between the diffs to the min and max longs
let halfDiff = (latDiff - longDiff)/2
minLong = minLong - halfDiff
maxLong = maxLong + halfDiff
}
}
if(spacing){
minLong = minLong - spacing
minLat = minLat - spacing
maxLong = maxLong + spacing
maxLat = maxLat + spacing
}
return new LngLatBounds(
new LngLat(minLong, minLat),
new LngLat(maxLong, maxLat)
);
}
public grainName(): string { public grainName(): string {
if (this.grain() !== pond.Grain.GRAIN_INVALID) { if (this.grain() !== pond.Grain.GRAIN_INVALID) {
if (this.grain() === pond.Grain.GRAIN_CUSTOM) { if (this.grain() === pond.Grain.GRAIN_CUSTOM) {

View file

@ -47,6 +47,7 @@ const Contract = lazy(() => import("pages/Contract"));
const JohnDeere = lazy(() => import("pages/JohnDeere")); 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"));
export const appendToUrl = (appendage: number | string) => { export const appendToUrl = (appendage: number | string) => {
const basePath = location.pathname.replace(/\/$/, ""); const basePath = location.pathname.replace(/\/$/, "");
@ -70,6 +71,7 @@ export default function Router() {
<Route path="terminals/*" element={<TerminalGatesRoute />} /> <Route path="terminals/*" element={<TerminalGatesRoute />} />
<Route path="jobsites/*" element={<JobsitesRoute />} /> <Route path="jobsites/*" element={<JobsitesRoute />} />
<Route path="heaters/*" element={<ObjectHeatersRoute />} /> <Route path="heaters/*" element={<ObjectHeatersRoute />} />
<Route path="fields/*" element={<FieldsRoute />} />
</Routes> </Routes>
) )
} }
@ -284,6 +286,21 @@ export default function Router() {
); );
}; };
const FieldsRoute = () => {
return (
<Routes>
<Route
path=""
element={<Fields />}
/>
<Route
path="/:fieldID"
element={<FieldPage />}
/>
</Routes>
);
}
if (isLoading) return null; if (isLoading) return null;
// if (!isAuthenticated) { // if (!isAuthenticated) {
// loginWithRedirect() // loginWithRedirect()
@ -325,7 +342,6 @@ export default function Router() {
{user.hasFeature("admin") && {user.hasFeature("admin") &&
<Route path="logs" element={<Logs />} /> <Route path="logs" element={<Logs />} />
} }
<Route path="fields" element={<Fields />} />
<Route path="marketplace" element={<Marketplace />} /> <Route path="marketplace" element={<Marketplace />} />
{user.hasFeature("admin") && ( {user.hasFeature("admin") && (
<Route path="firmware" element={<Firmware />} /> <Route path="firmware" element={<Firmware />} />

314
src/pages/Field.tsx Normal file
View file

@ -0,0 +1,314 @@
import { Field, fieldScope, HarvestPlan, teamScope } from "models";
import { useFieldAPI, useGlobalState, useHarvestPlanAPI } from "providers";
import React, { useCallback, useEffect, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import PageContainer from "./PageContainer";
import { useMobile, useSnackbar, useUserAPI } from "hooks";
import { Box, Card, Grid2, IconButton, Tab, Tabs, Theme, Typography, darken } from "@mui/material";
import FieldActions from "field/FieldActions";
import { Settings } from "@mui/icons-material";
import { pond } from "protobuf-ts/pond";
import FieldMinimap from "field/Fieldminimap";
import Weather from "weather/weather";
import TaskViewer from "tasks/TaskViewer";
import HarvestPlanDisplay from "harvestPlan/HarvestPlanDisplay";
import { cloneDeep } from "lodash";
import HarvestPlanTable from "harvestPlan/HarvestPlanTable";
import { makeStyles } from "@mui/styles";
import FieldSettings from "field/FieldSettings";
const useStyles = makeStyles((theme: Theme) => {
return ({
card: {
height: "100%",
bgcolor: darken(theme.palette.background.paper, 0.05),
},
sectionHeader: {
fontSize: 35,
fontWeight: 650
}
})
})
interface TabPanelProps {
children?: React.ReactNode;
index: any;
value: any;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
aria-labelledby={`simple-tab-${index}`}
style={{ height: "94%", paddingTop: "15px" }}
{...other}>
{value === index && <React.Fragment>{children}</React.Fragment>}
</div>
);
}
export default function FieldPage() {
const { state } = useLocation();
//const [{ as }] = useGlobalState()
const classes = useStyles();
const fieldAPI = useFieldAPI();
const userAPI = useUserAPI();
const [{as, user}] = useGlobalState();
const {openSnack} = useSnackbar();
const fieldID = useParams<{ fieldID: string }>()?.fieldID ?? "";
const [field, setField] = useState<Field>(state?.field ? Field.create(state.field) : Field.create())
const [permissions, setPermissions] = useState<pond.Permission[]>(state?.permissions ? state.permissions : [])
const isMobile = useMobile()
const [openFieldSettings, setOpenFieldSettings] = useState(false)
const [planLoading, setPlanLoading] = useState(false)
const [hPlan, setHPlan] = useState<HarvestPlan>(HarvestPlan.create());
const [loading, setLoading] = useState(false)
const hPlanAPI = useHarvestPlanAPI();
const [tabVal, setTabVal] = useState(0)
const navigate = useNavigate()
const loadField = useCallback(() => {
if(state.field){
fieldAPI.getField(state.field).then(resp => {
setField(Field.create(resp.data))
}).catch(err => {
})
}
},[as])
const goTo = () => {
navigate("/fields");
}
useEffect(()=>{
// if already loading do nothing
if(loading) return
//if the field is passed through in the state just use it
if(state?.field){
let field = Field.create(state.field)
field.permissions = state.permissions
setField(field)
setPermissions(state.permissions)
return
}
//otherwise load the field
setLoading(true)
fieldAPI.getField(fieldID, as).then(resp => {
setField(Field.create(resp.data))
}).catch(err => {
openSnack("Failed to load Field")
}).finally(() => {
setLoading(false)
})
//also get the permissions
let scope = fieldScope(fieldID);
if (as) {
scope = teamScope(as)
}
userAPI.getUser(user.id(), scope).then(resp => {
let f = cloneDeep(field)
f.permissions = resp.permissions
setField(f)
setPermissions(resp.permissions);
});
},[loadField, fieldID, as])
useEffect(() => {
if (field.key() !== "") {
hPlanAPI
.listHarvestPlans(1, 0, "desc", "createDate", field.key(), as)
.then(resp => {
if (resp.data.harvestPlan.length > 0) {
let plan = resp.data.harvestPlan[0];
setHPlan(HarvestPlan.any(plan));
} else {
setHPlan(HarvestPlan.create());
}
setPlanLoading(false);
})
.catch(err => {
//openSnack("Failed to load plan");
});
}
}, [field, as, hPlanAPI]);
const fieldActions = () => {
return (
<Grid2 container direction={"row"} justifyContent="space-between" alignContent="center" alignItems="center">
<Grid2>
<Typography className={classes.sectionHeader}>
{field.name()}
</Typography>
</Grid2>
<Grid2>
<IconButton
onClick={() => {
setOpenFieldSettings(true)
}}><Settings /></IconButton>
<FieldActions field={field} permissions={permissions} refreshCallback={()=>{}}/>
</Grid2>
</Grid2>
)
}
const map = () => {
return (
<Box minHeight={550} height="100%">
<FieldMinimap field={field} borderSpacing={0.001} squared/>
</Box>
)
}
const weather = () => {
return (<Weather longitude={field.center().longitude} latitude={field.center().latitude} />)
}
const harvestPlans = () => {
return (
<HarvestPlanDisplay
plan={hPlan}
permissions={permissions}
planField={field}
loading={planLoading}
changePlan={updatedPlan => {
if (updatedPlan) {
setHPlan(updatedPlan);
}
}}
/>)
}
const tasks = () => {
let taskLoadKeys: string[] = [];
if (!planLoading) {
field.key() !== "" && taskLoadKeys.push(field.key());
//hPlan.key() !== "" && taskLoadKeys.push(hPlan.key());
}
return (<TaskViewer drawerView objectKey={field.key()} loadKeys={taskLoadKeys} overlayButton={isMobile}/>)
}
const desktopView = () => {
return (
<React.Fragment>
<Box>
<Grid2 container spacing={1}>
<Grid2 size={4}>
<Card raised className={classes.card}>
{map()}
</Card>
</Grid2>
<Grid2 size={3}>
<Card raised className={classes.card}>
{weather()}
</Card>
</Grid2>
<Grid2 size={5}>
<Card raised className={classes.card}>
{tasks()}
</Card>
</Grid2>
</Grid2>
</Box>
<Box>
<Typography className={classes.sectionHeader}>
Harvest Overview
</Typography>
<Grid2 container spacing={1}>
<Grid2 size={4}>
<Card raised className={classes.card}>
{harvestPlans()}
</Card>
</Grid2>
<Grid2 size={8}>
<Card raised>
<HarvestPlanTable field={field}/>
</Card>
</Grid2>
</Grid2>
</Box>
</React.Fragment>
)
}
const mobileView = () => {
return (
<React.Fragment>
<Tabs
value={tabVal}
onChange={(_, val) => {
setTabVal(val)
}}
TabIndicatorProps={{ style: { background: "rgba(255,255,0,255)" } }}>
<Tab label="Overview" />
<Tab label="Activities" />
<Tab label="Harvest" />
</Tabs>
<TabPanel value={tabVal} index={0}>
<Grid2 container spacing={2} direction="column">
<Grid2 size={12}>
<Card raised sx={{height: "50vh"}}>
{map()}
</Card>
</Grid2>
<Grid2 size={12}>
<Card raised>
{weather()}
</Card>
</Grid2>
</Grid2>
</TabPanel>
<TabPanel value={tabVal} index={1}>
<Card raised>
{tasks()}
</Card>
</TabPanel>
<TabPanel value={tabVal} index={2}>
<Grid2>
<Grid2 size={12}>
<Card raised className={classes.card}>
{harvestPlans()}
</Card>
</Grid2>
<Grid2>
<HarvestPlanTable field={field}/>
</Grid2>
</Grid2>
</TabPanel>
</React.Fragment>
)
}
const fieldSettings = () => {
return (
<FieldSettings
open={openFieldSettings}
onClose={() => {
setOpenFieldSettings(false)
}}
selectedField={field}
updateFields={() => {
loadField()
}}
removeField={()=>{
goTo()
}}
/>
)
}
return (
<PageContainer>
<Box padding={2}>
{fieldActions()}
{isMobile ? mobileView() : desktopView()}
</Box>
{fieldSettings()}
</PageContainer>
)
}

View file

@ -39,8 +39,8 @@ export default function Heaters() {
objectHeaterAPI objectHeaterAPI
.listObjectHeatersPageData(pageSize, tablePage * pageSize, undefined, undefined, searchText, as) .listObjectHeatersPageData(pageSize, tablePage * pageSize, undefined, undefined, searchText, as)
.then(resp => { .then(resp => {
setHeaters(resp.data.heaterData ?? []);
setTotal(resp.data.total) setTotal(resp.data.total)
setHeaters(resp.data.heaterData);
}); });
}, [objectHeaterAPI, as, pageSize, tablePage, searchText]); }, [objectHeaterAPI, as, pageSize, tablePage, searchText]);

View file

@ -6,7 +6,7 @@ export default function Tasks() {
return ( return (
<PageContainer> <PageContainer>
<Box padding={2}> <Box padding={2}>
<TaskViewer /> <TaskViewer overlayButton/>
</Box> </Box>
</PageContainer> </PageContainer>
); );

View file

@ -5,10 +5,11 @@ import { useGlobalState } from "providers";
import React, { createContext, PropsWithChildren, useContext } from "react"; import React, { createContext, PropsWithChildren, useContext } from "react";
import { or } from "utils"; import { or } from "utils";
import { pondURL } from "./pond"; import { pondURL } from "./pond";
import { permissionToString } from "pbHelpers/Permission";
export interface IFieldAPIContext { export interface IFieldAPIContext {
addField: (field: pond.FieldSettings, otherTeam?: string) => Promise<any>; addField: (field: pond.FieldSettings, otherTeam?: string) => Promise<any>;
getField: (fieldId: string, otherTeam?: string) => Promise<any>; getField: (fieldId: string, otherTeam?: string) => Promise<AxiosResponse<pond.Field>>;
listFields: ( listFields: (
limit: number, limit: number,
offset: number, offset: number,
@ -24,7 +25,15 @@ export interface IFieldAPIContext {
field: pond.FieldSettings, field: pond.FieldSettings,
asRoot?: true, asRoot?: true,
otherTeam?: string otherTeam?: string
) => Promise<AxiosResponse<pond.UpdateSiteResponse>>; ) => Promise<AxiosResponse<pond.UpdateFieldResponse>>;
shareAll: (
email: string,
permissions: pond.Permission[],
) => Promise<AxiosResponse<any>>
shareAllByKey: (
key: string,
permissions: pond.Permission[]
) => Promise<AxiosResponse<any>>
} }
export const FieldAPIContext = createContext<IFieldAPIContext>({} as IFieldAPIContext); export const FieldAPIContext = createContext<IFieldAPIContext>({} as IFieldAPIContext);
@ -44,8 +53,8 @@ export default function FieldProvider(props: PropsWithChildren<Props>) {
const getField = (fieldId: string, otherTeam?: string) => { const getField = (fieldId: string, otherTeam?: string) => {
const view = otherTeam ? otherTeam : as const view = otherTeam ? otherTeam : as
if (view) return get(pondURL("/field/" + fieldId + "?as=" + view)); if (view) return get<pond.Field>(pondURL("/field/" + fieldId + "?as=" + view));
return get(pondURL("/field/" + fieldId)); return get<pond.Field>(pondURL("/field/" + fieldId));
}; };
const removeField = (key: string, otherTeam?: string) => { const removeField = (key: string, otherTeam?: string) => {
@ -93,6 +102,36 @@ export default function FieldProvider(props: PropsWithChildren<Props>) {
); );
}; };
const shareAll = (email: string, permissions: pond.Permission[]) => {
let url = pondURL("/shareAllFields")
if (as) url = url + "?as=" + as
return new Promise<AxiosResponse>((resolve, reject) => {
post(url, {
email: email,
permissions: permissions.map(permission => permissionToString(permission))
}).then(resp => {
return resolve(resp)
}).catch(err => {
return reject(err)
})
})
}
const shareAllByKey = (key: string, permissions: pond.Permission[]) => {
let url = pondURL("/shareAllFieldsByKey")
if (as) url = url + "?as=" + as
return new Promise<AxiosResponse>((resolve, reject) => {
post(url, {
key: key,
permissions: permissions.map(permission => permissionToString(permission))
}).then(resp => {
return resolve(resp)
}).catch(err => {
return reject(err)
})
})
}
return ( return (
<FieldAPIContext.Provider <FieldAPIContext.Provider
value={{ value={{
@ -100,7 +139,9 @@ export default function FieldProvider(props: PropsWithChildren<Props>) {
getField, getField,
listFields, listFields,
removeField, removeField,
updateField updateField,
shareAll,
shareAllByKey
}}> }}>
{children} {children}
</FieldAPIContext.Provider> </FieldAPIContext.Provider>

View file

@ -135,7 +135,6 @@ export default function PermissionProvider(props: PropsWithChildren<Props>) {
let url = pondURL("/" + scope.kind + "s/" + scope.key + "/shareByKey") let url = pondURL("/" + scope.kind + "s/" + scope.key + "/shareByKey")
if (as) url = pondURL(`/${scope.kind}s/${scope.key}/shareByKey?as=${as}`) if (as) url = pondURL(`/${scope.kind}s/${scope.key}/shareByKey?as=${as}`)
return new Promise<AxiosResponse>((resolve, reject) => { return new Promise<AxiosResponse>((resolve, reject) => {
post(url, { post(url, {
key: key, key: key,
permissions: permissions.map(permission => permissionToString(permission)) permissions: permissions.map(permission => permissionToString(permission))

View file

@ -2,7 +2,7 @@ import React, { useState } from "react";
import { EventClickArg } from "@fullcalendar/core"; import { EventClickArg } from "@fullcalendar/core";
import { DateClickArg } from "@fullcalendar/interaction"; import { DateClickArg } from "@fullcalendar/interaction";
import { useGlobalState } from "providers"; import { useGlobalState } from "providers";
import { useMobile, useSnackbar, useThemeType } from "hooks"; import { useMobile, useSnackbar } from "hooks";
import { useTaskAPI } from "providers"; import { useTaskAPI } from "providers";
import { useEffect, useCallback } from "react"; import { useEffect, useCallback } from "react";
import TaskCalendar from "./TaskCalendar"; import TaskCalendar from "./TaskCalendar";
@ -12,7 +12,6 @@ import {
Button, Button,
Fab, Fab,
Grid2 as Grid, Grid2 as Grid,
IconButton,
LinearProgress, LinearProgress,
Theme, Theme,
Typography Typography
@ -21,8 +20,6 @@ import { Task } from "models";
import TaskSettings from "./TaskSettings"; import TaskSettings from "./TaskSettings";
import { DayPicker } from "react-day-picker"; import { DayPicker } from "react-day-picker";
import "react-day-picker/dist/style.css"; import "react-day-picker/dist/style.css";
import { CalendarToday } from "@mui/icons-material";
import NotesIcon from "@mui/icons-material/Notes";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import TaskDrawer from "./TaskDrawer"; import TaskDrawer from "./TaskDrawer";
import moment from "moment"; import moment from "moment";
@ -33,6 +30,7 @@ interface ViewProps {
label?: string; label?: string;
drawerView?: boolean; drawerView?: boolean;
loadKeys?: string[]; //if you want to load tasks for a specific object(s) loadKeys?: string[]; //if you want to load tasks for a specific object(s)
overlayButton?: boolean;
} }
interface TabPanelProps { interface TabPanelProps {
@ -42,17 +40,7 @@ interface TabPanelProps {
} }
const useStyles = makeStyles((theme: Theme) => { const useStyles = makeStyles((theme: Theme) => {
const themeType = useThemeType();
return ({ return ({
avatar: {
color: themeType === "light" ? theme.palette.common.black : theme.palette.common.white,
backgroundColor: "transparent",
width: theme.spacing(5),
height: theme.spacing(5),
border: "1px solid",
borderColor: theme.palette.divider
},
active: { active: {
color: theme.palette.getContrastText(theme.palette.secondary.main), color: theme.palette.getContrastText(theme.palette.secondary.main),
backgroundColor: theme.palette.secondary.main, backgroundColor: theme.palette.secondary.main,
@ -64,6 +52,22 @@ const useStyles = makeStyles((theme: Theme) => {
} }
}, },
fab: { fab: {
zIndex: 20,
background: theme.palette.primary.main,
"&:hover": {
background: theme.palette.primary.main
},
"&:focus": {
background: theme.palette.primary.main
},
position: "absolute",
bottom: theme.spacing(8), //for mobile navigator
right: theme.spacing(2),
[theme.breakpoints.up("sm")]: {
bottom: theme.spacing(2)
}
},
overlayFab: {
zIndex: 20, zIndex: 20,
background: theme.palette.primary.main, background: theme.palette.primary.main,
"&:hover": { "&:hover": {
@ -83,19 +87,19 @@ const useStyles = makeStyles((theme: Theme) => {
}); });
export default function TaskViewer(props: ViewProps) { export default function TaskViewer(props: ViewProps) {
const { objectKey, label, drawerView, loadKeys } = props; const { objectKey, label, drawerView, loadKeys, overlayButton } = props;
const [{ user }] = useGlobalState(); const [{ user, as }] = useGlobalState();
const [{ as }] = useGlobalState(); //const [{ as }] = useGlobalState();
const taskAPI = useTaskAPI(); const taskAPI = useTaskAPI();
const { openSnack } = useSnackbar(); const { openSnack } = useSnackbar();
const [tasks, setTasks] = useState<Map<string, Task>>(new Map<string, Task>()); const [tasks, setTasks] = useState<Map<string, Task>>(new Map<string, Task>([]));
const [viewTask, setViewTask] = useState<Task>(Task.create()); const [viewTask, setViewTask] = useState<Task>();
const [newTaskDialog, setNewTaskDialog] = useState(false); const [newTaskDialog, setNewTaskDialog] = useState(false);
const [editTask, setEditTask] = useState<Task>(); const [editTask, setEditTask] = useState<Task>();
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const isMobile = useMobile(); const isMobile = useMobile();
const [value, setValue] = React.useState(0); const [value, setValue] = useState(0);
const classes = useStyles(); const classes = useStyles();
const [expand, setExpand] = useState(false); const [expand, setExpand] = useState(false);
const [hideCal, setHideCal] = useState(false); const [hideCal, setHideCal] = useState(false);
@ -146,11 +150,13 @@ export default function TaskViewer(props: ViewProps) {
taskAPI taskAPI
.getMultiTasks(loadKeys, as) .getMultiTasks(loadKeys, as)
.then(resp => { .then(resp => {
if(resp.data.tasks){
resp.data.tasks.forEach(task => { resp.data.tasks.forEach(task => {
if (task.settings) { if (task.settings) {
temp.set(task.key, Task.any(task)); temp.set(task.key, Task.any(task));
} }
}); });
}
setTasks(temp); setTasks(temp);
setLoaded(true); setLoaded(true);
}) })
@ -167,15 +173,18 @@ export default function TaskViewer(props: ViewProps) {
taskAPI taskAPI
.listTasks(50, 0, "asc", "start", undefined, undefined, as, prevMonth, nextMonth) .listTasks(50, 0, "asc", "start", undefined, undefined, as, prevMonth, nextMonth)
.then(resp => { .then(resp => {
if(resp.data.tasks){
resp.data.tasks.forEach(task => { resp.data.tasks.forEach(task => {
if (task.settings) { if (task.settings) {
temp.set(task.key, Task.any(task)); temp.set(task.key, Task.any(task));
} }
}); });
}
setTasks(temp); setTasks(temp);
setLoaded(true); setLoaded(true);
}) })
.catch(err => { .catch(err => {
console.log(err)
openSnack("Failed to load tasks"); openSnack("Failed to load tasks");
}); });
}, [taskAPI, as, user, openSnack, nextMonth, prevMonth]); }, [taskAPI, as, user, openSnack, nextMonth, prevMonth]);
@ -216,33 +225,6 @@ export default function TaskViewer(props: ViewProps) {
}); });
}; };
const tabIcons = () => {
return (
<Box display="flex" justifyContent="left" alignItems="center" zIndex={5}>
<IconButton
className={value === 0 ? classes.active : classes.avatar}
component="span"
style={{
marginBottom: "0.75rem",
marginRight: "0.5rem"
}}
onClick={() => setValue(0)}>
<CalendarToday />
</IconButton>
<IconButton
className={value === 1 ? classes.active : classes.avatar}
component="span"
style={{
marginBottom: "0.75rem",
marginRight: "0.5rem"
}}
onClick={() => setValue(1)}>
<NotesIcon />
</IconButton>
</Box>
);
};
function TabPanelMine(props: TabPanelProps) { function TabPanelMine(props: TabPanelProps) {
const { children, value, index, ...other } = props; const { children, value, index, ...other } = props;
@ -266,15 +248,7 @@ export default function TaskViewer(props: ViewProps) {
return ( return (
<React.Fragment> <React.Fragment>
{isMobile || drawerView ? ( {isMobile || drawerView ? (
<Box> <Box height={"100%"} position={"relative"}>
<Fab
onClick={openDialog}
aria-label="Create Task"
className={classes.fab}
size={isMobile ? "medium" : "large"}>
<AddIcon />
</Fab>
{!drawerView && tabIcons()}
<TabPanelMine value={value} index={0}> <TabPanelMine value={value} index={0}>
<Box paddingBottom={2}> <Box paddingBottom={2}>
<TaskCalendar <TaskCalendar
@ -347,6 +321,13 @@ export default function TaskViewer(props: ViewProps) {
openTask={(taskId: string) => openSelectedTask(taskId)} openTask={(taskId: string) => openSelectedTask(taskId)}
/> />
</TabPanelMine> </TabPanelMine>
<Fab
onClick={openDialog}
aria-label="Create Task"
className={overlayButton ? classes.overlayFab : classes.fab}
size={isMobile ? "medium" : "large"}>
<AddIcon />
</Fab>
</Box> </Box>
) : ( ) : (
<Grid container direction="row" alignContent="center" alignItems="center"> <Grid container direction="row" alignContent="center" alignItems="center">
@ -436,7 +417,7 @@ export default function TaskViewer(props: ViewProps) {
}; };
return ( return (
<Box> <Box height={"100%"}>
{!loaded ? <LinearProgress style={{ marginTop: 20 }} /> : viewer()} {!loaded ? <LinearProgress style={{ marginTop: 20 }} /> : viewer()}
<TaskSettings <TaskSettings
open={newTaskDialog} open={newTaskDialog}
@ -455,7 +436,7 @@ export default function TaskViewer(props: ViewProps) {
setNewTaskDialog(false); setNewTaskDialog(false);
}} }}
/> />
{ {viewTask &&
<TaskDrawer <TaskDrawer
task={viewTask} task={viewTask}
open={openDrawer} open={openDrawer}