diff --git a/package-lock.json b/package-lock.json index f945c99..0d09379 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "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", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", diff --git a/package.json b/package.json index ee0e9b7..3abca39 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "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", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", diff --git a/src/bin/BinsList.tsx b/src/bin/BinsList.tsx index 71a9240..483df50 100644 --- a/src/bin/BinsList.tsx +++ b/src/bin/BinsList.tsx @@ -124,7 +124,7 @@ export default function BinsList(props: Props) { xs: 6, sm: 4, md: 3, - lg: 2, + lg: 1.5, }} key={i} className={classes.gridListTile} diff --git a/src/common/DisplayDrawer.tsx b/src/common/DisplayDrawer.tsx index 8e31818..8497d0b 100644 --- a/src/common/DisplayDrawer.tsx +++ b/src/common/DisplayDrawer.tsx @@ -92,7 +92,7 @@ export default function DisplayDrawer(props: Props) { classes={{ paper: classes.drawerPaper }}> + style={{ width: isMobile ? "100%" : width, height: isMobile ? drawerHeight + "vh" : 0}}> diff --git a/src/component/ComponentCard.tsx b/src/component/ComponentCard.tsx index bf83696..6d430d5 100644 --- a/src/component/ComponentCard.tsx +++ b/src/component/ComponentCard.tsx @@ -285,7 +285,6 @@ export default function ComponentCard(props: Props) { // ) : ( diff --git a/src/field/FieldList.tsx b/src/field/FieldList.tsx index 6243ce3..b22e23b 100644 --- a/src/field/FieldList.tsx +++ b/src/field/FieldList.tsx @@ -1,7 +1,13 @@ import { + Accordion, + AccordionDetails, + AccordionSummary, Box, Button, + Grid2, + IconButton, Paper, + Stack, Tab, Table, TableBody, @@ -9,16 +15,26 @@ import { TableContainer, TableHead, TableRow, - Tabs + Tabs, + Typography } from "@mui/material"; import React, { useCallback, useEffect, useState } from "react"; -import { useMobile, useSnackbar } from "hooks"; -import { useGlobalState, useFieldAPI } from "providers"; +import { useMobile, useSnackbar, useUserAPI } from "hooks"; +import { useGlobalState, useFieldAPI, useJohnDeereProxyAPI, useCNHiProxyAPI } from "providers"; //import HarvestTable from "harvestPlan/HarvestTable"; -import { Field } from "models"; +import { Field, fieldScope, teamScope } from "models"; import GrainDescriber from "grain/GrainDescriber"; import { pond } from "protobuf-ts/pond"; 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 { children?: React.ReactNode; @@ -26,128 +42,244 @@ interface TabPanelProps { value: any; } -function TabPanelMine(props: TabPanelProps) { - const { children, value, index, ...other } = props; - - return ( - - ); -} - export default function FieldList() { const [{ as }] = useGlobalState(); const isMobile = useMobile(); const fieldAPI = useFieldAPI(); + const jdAPI = useJohnDeereProxyAPI(); + const cnhAPI = useCNHiProxyAPI(); const { openSnack } = useSnackbar(); - const [fields, setFields] = useState([]); - const [value, setValue] = React.useState(0); + const [tabValue, setTabValue] = React.useState(0); const [openHarvestSettings, setOpenHarvestSettings] = useState(false); - const [fieldForPlan, setFieldForPlan] = useState(Field.create()); + // const [fieldForPlan, setFieldForPlan] = useState(Field.create()); + const [fields, setFields] = useState([]); + const [total, setTotal] = useState(0) + const [rowsPerPage, setRowsPerPage] = useState(5) + const [page, setPage] = useState(0) + const [selectedField, setSelectedField] = useState() + const [openFieldSettings, setOpenFieldSettings] = useState(false) + const [shareOpen, setShareOpen] = useState(false) + const navigate = useNavigate(); const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { - setValue(newValue); + setTabValue(newValue); + setPage(0) }; - const loadFields = useCallback(() => { + const loadAdaptiveFields = useCallback(() => { fieldAPI - .listFields(500, 0, "asc", "fieldName", undefined, as) + .listFields(rowsPerPage, page*rowsPerPage, "asc", "fieldName", undefined, as) .then(resp => { - setFields(resp.data.fields.map(f => Field.any(f))); - // resp.data.fields.forEach(f => { - // if (f.settings) { - // fieldAPI.updateField(f.settings.key, f.settings); - // } - // }); + setFields(resp.data.fields.map(f => Field.create(f))); + setTotal(resp.data.total) }) .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(() => { - loadFields(); - }, [loadFields]); + //based on the current tab load the correct fields + switch(tabValue){ + case 0: + loadAdaptiveFields(); + break; + case 1: + loadJDFields(); + break; + case 2: + loadCNHFields(); + break; + } + }, [loadAdaptiveFields, loadJDFields, loadCNHFields, tabValue]); - const calcTotalAcres = () => { - let totalAcres = 0; - fields.forEach(field => { - totalAcres += field.acres(); - }); - totalAcres = Math.round(totalAcres); - return totalAcres; - }; + const goTo = (field: Field) => { + let path = field.key(); + navigate(path, { state: {field: field, permissions: field.permissions} }); + } - const listFieldsInfo = fields.map((field, index) => ( - - {field.fieldName()} - {field.landLoc()} - - {field.crop() === pond.Grain.GRAIN_CUSTOM - ? field.customType() - : GrainDescriber(field.crop()).name} - - {field.calculateAcres()} - - {field.permissions.includes(pond.Permission.PERMISSION_WRITE) && ( - - )} - - - )); + const fieldActions = (field: Field) => { + return ( + + { + setSelectedField(field) + setOpenFieldSettings(true) + }}> + {}}/> + {isMobile && {goTo(field)}}>} + + ) + } + +const columns: Column[] = [ + { + title: "View", + render: row => { + return ( + + + + ) + } + }, + { + title: "Field Name", + render: row => { + return {row.name()} + } + }, + { + title: "Current Crop", + render: row => { + return {GrainDescriber(row.crop()).name} + } + }, + { + title: "Acres", + render: row => { + return {row.acres()} + } + }, + { + title: "Actions", + hidden: tabValue > 0, + render: row => fieldActions(row) + } +] + + const fieldTable = (fields: Field[]) => { + return ( + + 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 ( + + }> + + + + {row.name()} + + + + {fieldActions(row)} + + + + + + + + + + + + + {row.grainName()} + {row.acres()} Acres + + + + + + + ) + }} + /> + ) + } return ( + - - {/* {!isMobile && } - {!isMobile && } */} + + + - - - - - - Field Name - Land Location - Main Crop Type - Acres ({calcTotalAcres()} Total) - Create New Plan - - - {listFieldsInfo} -
-
-
- {/* need to re-factor the harvest table file before restoring these functions */} - {/* - - - - - */} - setOpenHarvestSettings(false)} field={fieldForPlan} + /> */} + { + //will need to re-load the fields after one is deleted + loadAdaptiveFields() + }} + open={openFieldSettings} + onClose={() => { + setOpenFieldSettings(false); + }} /> + {setShareOpen(false)}}/>
); } \ No newline at end of file diff --git a/src/field/FieldSettings.tsx b/src/field/FieldSettings.tsx index ca865a0..33f0c5d 100644 --- a/src/field/FieldSettings.tsx +++ b/src/field/FieldSettings.tsx @@ -152,9 +152,7 @@ export default function FieldSettings(props: Props) { const fieldInformation = () => { return ( - - - Field Information + setFeatColor(color)} /> - + + ); + }; + + return ( + + Field Information + {fieldInformation()} @@ -282,13 +287,6 @@ export default function FieldSettings(props: Props) { -
- ); - }; - - return ( - - {fieldInformation()} ); } diff --git a/src/field/Fieldminimap.tsx b/src/field/Fieldminimap.tsx new file mode 100644 index 0000000..d657ae5 --- /dev/null +++ b/src/field/Fieldminimap.tsx @@ -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(); + + 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 ? + ( + + + + ) + : + () +} \ No newline at end of file diff --git a/src/field/ShareAllFields.tsx b/src/field/ShareAllFields.tsx new file mode 100644 index 0000000..587cddd --- /dev/null +++ b/src/field/ShareAllFields.tsx @@ -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("") + const [user, setUser] = useState("") + const [sharedPermissions, setSharedPermissions] = useState([ + 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 ( + + + User + + { + setTeamShare(checked) + }} + name="storage" + /> + + Team + + {teamShare ? + + {setTeam(teamKey)}}/> + : + + { + setUser(e.target.value) + }} + /> + } + + ) + // 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 ( + + + Permissions + + {changePermissions(checked, pond.Permission.PERMISSION_READ)}} + value={pond.Permission.PERMISSION_READ as pond.Permission} + /> + } + label="View" + labelPlacement="end" + /> + {changePermissions(checked, pond.Permission.PERMISSION_WRITE)}} + value={pond.Permission.PERMISSION_WRITE as pond.Permission} + /> + } + label="Edit" + labelPlacement="end" + /> + {changePermissions(checked, pond.Permission.PERMISSION_SHARE)}} + value={pond.Permission.PERMISSION_SHARE as pond.Permission} + /> + } + label="Share" + labelPlacement="end" + /> + {changePermissions(checked, pond.Permission.PERMISSION_USERS)}} + value={pond.Permission.PERMISSION_USERS as pond.Permission} + /> + } + label="Users" + labelPlacement="end" + /> + {changePermissions(checked, pond.Permission.PERMISSION_FILE_MANAGEMENT)}} + value={pond.Permission.PERMISSION_FILE_MANAGEMENT as pond.Permission} + /> + } + label="Files" + labelPlacement="end" + /> + {/* billing */} + {changePermissions(checked, pond.Permission.PERMISSION_BILLING)}} + value={pond.Permission.PERMISSION_BILLING as pond.Permission} + /> + } + label="Billing" + labelPlacement="end" + /> + + + + ) + } + + const actions = () => { + //the buttons to shaare or cancel + return ( + + + + + ) + } + + return ( + + Share All Fields + + + {target()} + {permissions()} + + + + {actions()} + + + ) +} \ No newline at end of file diff --git a/src/harvestPlan/DuplicateHarvestPlan.tsx b/src/harvestPlan/DuplicateHarvestPlan.tsx index 5f083af..a3de595 100644 --- a/src/harvestPlan/DuplicateHarvestPlan.tsx +++ b/src/harvestPlan/DuplicateHarvestPlan.tsx @@ -7,16 +7,17 @@ import { TextField } from "@mui/material"; import ResponsiveDialog from "common/ResponsiveDialog"; +import { Option } from "common/SearchSelect"; import { Field, HarvestPlan, Task } from "models"; import moment from "moment"; 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"; interface Props { open: boolean; close: () => void; - fields: Field[]; + fields?: Field[]; plan: HarvestPlan; } @@ -29,10 +30,14 @@ export default function DuplicateHarvestPlan(props: Props) { const harvestPlanAPI = useHarvestPlanAPI(); const taskAPI = useTaskAPI(); const [ready, setReady] = useState(false); + const [fieldOptions, setFieldOptions] = useState([]) + const fieldAPI = useFieldAPI(); const loadTasks = useCallback(() => { taskAPI.listTasks(50, 0, "asc", "start", plan.key(), undefined).then(resp => { - setPlanTasks(resp.data.tasks.map(t => Task.any(t))); + if(resp.data.tasks){ + setPlanTasks(resp.data.tasks.map(t => Task.any(t))); + } setReady(true); }); }, [taskAPI, plan]); @@ -41,9 +46,25 @@ export default function DuplicateHarvestPlan(props: Props) { 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 = () => { let planSettings = HarvestPlan.clone(plan).settings; - planSettings.field = fields[newFieldIndex].key(); + planSettings.field = fieldOptions[newFieldIndex].key(); planSettings.createDate = moment.now(); if (title !== "") { planSettings.title = title; @@ -69,7 +90,7 @@ export default function DuplicateHarvestPlan(props: Props) { value={newFieldIndex} onChange={e => setNewFieldIndex(+e.target.value)} color="primary"> - {fields.map((option, i) => ( + {fieldOptions.map((option, i) => ( {option.fieldName()} @@ -98,8 +119,8 @@ export default function DuplicateHarvestPlan(props: Props) { color="primary" disabled={ !ready || - (fields[newFieldIndex] && - !fields[newFieldIndex].permissions.includes(pond.Permission.PERMISSION_WRITE)) + (fieldOptions[newFieldIndex] && + !fieldOptions[newFieldIndex].permissions.includes(pond.Permission.PERMISSION_WRITE)) }> Duplicate diff --git a/src/harvestPlan/HarvestPlanActions.tsx b/src/harvestPlan/HarvestPlanActions.tsx index 441446a..f29c1e4 100644 --- a/src/harvestPlan/HarvestPlanActions.tsx +++ b/src/harvestPlan/HarvestPlanActions.tsx @@ -13,7 +13,7 @@ import ShareObjectIcon from "@mui/icons-material/Share"; import ObjectUsersIcon from "@mui/icons-material/AccountCircle"; import ObjectTeamsIcon from "@mui/icons-material/SupervisedUserCircle"; import { pond } from "protobuf-ts/pond"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import ObjectUsers from "user/ObjectUsers"; import ShareObject from "user/ShareObject"; import { isOffline } from "utils/environment"; @@ -43,7 +43,7 @@ const useStyles = makeStyles((theme: Theme) => ({ interface Props { plan: HarvestPlan; planField: Field; - fieldList: Field[]; + fieldList?: Field[]; permissions: pond.Permission[]; refreshCallback: (updatedPlan?: HarvestPlan) => void; hideNew?: boolean; @@ -215,7 +215,7 @@ export default function HarvestPlanActions(props: Props) { setUpdatePlan(true); setOpenState({ ...openState, settings: true }); }}> - Edit or Add Activity + Activities )} void; } @@ -175,7 +175,7 @@ export default function HarvestPlanDisplay(props: Props) { - Crop Plan{plan.key() !== "" ? " - " + plan.settings.title : " - None Found"} + {plan.key() !== "" ? plan.settings.title : "None Found"} {!isMobile && permissions.includes(pond.Permission.PERMISSION_WRITE) && ( ({ + 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([]) + 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[] = [ + //these columns would be the ones on by default + //field - hideable + { + title: "Field", + hidden: field !== undefined, + render: (row) => {row.field()} + }, + //title + { + title: "Plan", + render: (row) => {row.settings.title} + }, + //year + { + title: "Year", + render: (row) => {row.harvestYear()} + }, + //grain - crop_type + { + title: "Grain", + render: (row) => {GrainDescriber(row.cropType()).name} + }, + //variant - grain_type + { + title: "Variant", + render: (row) => {row.grainType()} + }, + //yield target + { + title: "Yield Target (bu)", + render: (row) => {row.yieldTarget()} + }, + //actual yield + { + title: "Actual Yield (bu)", + render: (row) => {row.actualYield()} + }, + //bushel price + { + title: "Bushel Price", + render: (row) => {row.bushelPrice()} + }, + //material cost + { + title: "Material Cost", + render: (row) => {row.totalMaterialCost()} + }, + //equipment cost + { + title: "Equipment Cost", + render: (row) => {row.totalEquipmentCost()} + } + //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 ( + + 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 ( + + }> + + {row.settings.title} - {row.harvestYear()} + + + + {/* grain */} + + Grain: + {GrainDescriber(row.cropType()).name} + + {/* variant */} + + Variant: + {row.grainType()} + + {/* yield target */} + {/* + Yield Target: + {row.yieldTarget()}bu + */} + {/* actual yield */} + + Actual Yield: + {row.actualYield()}bu + + {/* bushel price */} + + Bushel Price: + ${row.bushelPrice()} + + {/* material cost */} + + Material Cost: + ${row.totalMaterialCost() * field.acres()} + + {/* equipment cost */} + + Equipment Cost: + ${row.totalEquipmentCost() * field.acres()} + + {/* 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 */} + + Total Cost: + ${(row.totalEquipmentCost() + row.totalMaterialCost()) * field.acres()} + + + + ) + }} + /> + ) +} \ No newline at end of file diff --git a/src/maps/mapDrawers/FieldDrawer.tsx b/src/maps/mapDrawers/FieldDrawer.tsx index c4029aa..14f0da7 100644 --- a/src/maps/mapDrawers/FieldDrawer.tsx +++ b/src/maps/mapDrawers/FieldDrawer.tsx @@ -273,7 +273,7 @@ export default function FieldDrawer(props: Props) { - + ); diff --git a/src/maps/mapLayers/geoMapLayer.tsx b/src/maps/mapLayers/geoMapLayer.tsx index 5b90854..1923b64 100644 --- a/src/maps/mapLayers/geoMapLayer.tsx +++ b/src/maps/mapLayers/geoMapLayer.tsx @@ -6,7 +6,7 @@ interface Props { objectCollection: FeatureCollection; measurementCollection?: FeatureCollection; showTitle?: boolean; - setInteractiveLayers: (layers: string[]) => void; + setInteractiveLayers?: (layers: string[]) => void; } export default function GeoMapLayer(props: Props) { @@ -18,7 +18,9 @@ export default function GeoMapLayer(props: Props) { // }, [measurementCollection, objectCollection]); useEffect(() => { - setInteractiveLayers(["shapefill", "shapeborder", "measurementBorder"]); + if(setInteractiveLayers){ + setInteractiveLayers(["shapefill", "shapeborder", "measurementBorder"]); + } }, []); // eslint-disable-line react-hooks/exhaustive-deps return ( @@ -60,6 +62,7 @@ export default function GeoMapLayer(props: Props) { /> )} + {measurementCollection && + } ); } diff --git a/src/models/Field.ts b/src/models/Field.ts index 25b3263..9ccdb4e 100644 --- a/src/models/Field.ts +++ b/src/models/Field.ts @@ -5,18 +5,21 @@ import area from "@turf/area"; import { Feature } from "geojson"; import { GeometryMapping } from "./GeometryMapping"; import GrainDescriber from "grain/GrainDescriber"; +import { LngLat, LngLatBounds } from "mapbox-gl"; export class Field { public settings: pond.FieldSettings = pond.FieldSettings.create(); public status: pond.FieldStatus = pond.FieldStatus.create(); - public permissions: pond.Permission[] = []; + public permissions: pond.Permission[] = []; public static create(pf?: pond.Field): Field { let my = new Field(); if (pf) { - my.settings = pond.FieldSettings.fromObject(cloneDeep(or(pf.settings, {}))); - my.status = pond.FieldStatus.fromObject(cloneDeep(or(pf.status, {}))); - my.permissions = pf.fieldPermissions; + //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 + let field = pond.Field.fromObject(cloneDeep(or(pf, pond.Field.create()))) + my.settings = field.settings ? field.settings : pond.FieldSettings.create() + my.status = field.status ? field.status : pond.FieldStatus.create() + my.permissions = field.fieldPermissions } return my; } @@ -152,6 +155,60 @@ export class Field { 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 { if (this.grain() !== pond.Grain.GRAIN_INVALID) { if (this.grain() === pond.Grain.GRAIN_CUSTOM) { diff --git a/src/navigation/Router.tsx b/src/navigation/Router.tsx index afe12c5..c54c291 100644 --- a/src/navigation/Router.tsx +++ b/src/navigation/Router.tsx @@ -47,6 +47,7 @@ const Contract = lazy(() => import("pages/Contract")); const JohnDeere = lazy(() => import("pages/JohnDeere")); const CNHi = lazy(() => import("pages/CNHi")); const LibraCart = lazy(() => import("pages/LibraCart")); +const FieldPage = lazy(()=>import("pages/Field")); export const appendToUrl = (appendage: number | string) => { const basePath = location.pathname.replace(/\/$/, ""); @@ -70,6 +71,7 @@ export default function Router() { } /> } /> } /> + } /> ) } @@ -284,6 +286,21 @@ export default function Router() { ); }; + const FieldsRoute = () => { + return ( + + } + /> + } + /> + + ); + } + if (isLoading) return null; // if (!isAuthenticated) { // loginWithRedirect() @@ -325,7 +342,6 @@ export default function Router() { {user.hasFeature("admin") && } /> } - } /> } /> {user.hasFeature("admin") && ( } /> diff --git a/src/pages/Field.tsx b/src/pages/Field.tsx new file mode 100644 index 0000000..19472ae --- /dev/null +++ b/src/pages/Field.tsx @@ -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 ( + + ); +} + +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(state?.field ? Field.create(state.field) : Field.create()) + const [permissions, setPermissions] = useState(state?.permissions ? state.permissions : []) + const isMobile = useMobile() + const [openFieldSettings, setOpenFieldSettings] = useState(false) + const [planLoading, setPlanLoading] = useState(false) + const [hPlan, setHPlan] = useState(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 ( + + + + {field.name()} + + + + { + setOpenFieldSettings(true) + }}> + {}}/> + + + ) + } + + const map = () => { + return ( + + + + ) + } + + const weather = () => { + return () + } + + const harvestPlans = () => { + return ( + { + if (updatedPlan) { + setHPlan(updatedPlan); + } + }} + />) + } + + const tasks = () => { + let taskLoadKeys: string[] = []; + if (!planLoading) { + field.key() !== "" && taskLoadKeys.push(field.key()); + //hPlan.key() !== "" && taskLoadKeys.push(hPlan.key()); + } + return () + } + + const desktopView = () => { + return ( + + + + + + {map()} + + + + + {weather()} + + + + + {tasks()} + + + + + + + Harvest Overview + + + + + {harvestPlans()} + + + + + + + + + + + ) + } + + const mobileView = () => { + return ( + + { + setTabVal(val) + }} + TabIndicatorProps={{ style: { background: "rgba(255,255,0,255)" } }}> + + + + + + + + + {map()} + + + + + {weather()} + + + + + + + {tasks()} + + + + + + + {harvestPlans()} + + + + + + + + + ) + } + + const fieldSettings = () => { + return ( + { + setOpenFieldSettings(false) + }} + selectedField={field} + updateFields={() => { + loadField() + }} + removeField={()=>{ + goTo() + }} + /> + ) + } + + return ( + + + {fieldActions()} + {isMobile ? mobileView() : desktopView()} + + {fieldSettings()} + + ) +} \ No newline at end of file diff --git a/src/pages/Heaters.tsx b/src/pages/Heaters.tsx index df716a0..53e6c0e 100644 --- a/src/pages/Heaters.tsx +++ b/src/pages/Heaters.tsx @@ -39,8 +39,8 @@ export default function Heaters() { objectHeaterAPI .listObjectHeatersPageData(pageSize, tablePage * pageSize, undefined, undefined, searchText, as) .then(resp => { + setHeaters(resp.data.heaterData ?? []); setTotal(resp.data.total) - setHeaters(resp.data.heaterData); }); }, [objectHeaterAPI, as, pageSize, tablePage, searchText]); diff --git a/src/pages/Tasks.tsx b/src/pages/Tasks.tsx index 85b97e1..2709b33 100644 --- a/src/pages/Tasks.tsx +++ b/src/pages/Tasks.tsx @@ -6,7 +6,7 @@ export default function Tasks() { return ( - + ); diff --git a/src/providers/pond/fieldAPI.tsx b/src/providers/pond/fieldAPI.tsx index 37be160..be980eb 100644 --- a/src/providers/pond/fieldAPI.tsx +++ b/src/providers/pond/fieldAPI.tsx @@ -5,10 +5,11 @@ import { useGlobalState } from "providers"; import React, { createContext, PropsWithChildren, useContext } from "react"; import { or } from "utils"; import { pondURL } from "./pond"; +import { permissionToString } from "pbHelpers/Permission"; export interface IFieldAPIContext { addField: (field: pond.FieldSettings, otherTeam?: string) => Promise; - getField: (fieldId: string, otherTeam?: string) => Promise; + getField: (fieldId: string, otherTeam?: string) => Promise>; listFields: ( limit: number, offset: number, @@ -24,7 +25,15 @@ export interface IFieldAPIContext { field: pond.FieldSettings, asRoot?: true, otherTeam?: string - ) => Promise>; + ) => Promise>; + shareAll: ( + email: string, + permissions: pond.Permission[], + ) => Promise> + shareAllByKey: ( + key: string, + permissions: pond.Permission[] + ) => Promise> } export const FieldAPIContext = createContext({} as IFieldAPIContext); @@ -44,8 +53,8 @@ export default function FieldProvider(props: PropsWithChildren) { const getField = (fieldId: string, otherTeam?: string) => { const view = otherTeam ? otherTeam : as - if (view) return get(pondURL("/field/" + fieldId + "?as=" + view)); - return get(pondURL("/field/" + fieldId)); + if (view) return get(pondURL("/field/" + fieldId + "?as=" + view)); + return get(pondURL("/field/" + fieldId)); }; const removeField = (key: string, otherTeam?: string) => { @@ -93,6 +102,36 @@ export default function FieldProvider(props: PropsWithChildren) { ); }; + const shareAll = (email: string, permissions: pond.Permission[]) => { + let url = pondURL("/shareAllFields") + if (as) url = url + "?as=" + as + return new Promise((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((resolve, reject) => { + post(url, { + key: key, + permissions: permissions.map(permission => permissionToString(permission)) + }).then(resp => { + return resolve(resp) + }).catch(err => { + return reject(err) + }) + }) + } + return ( ) { getField, listFields, removeField, - updateField + updateField, + shareAll, + shareAllByKey }}> {children} diff --git a/src/providers/pond/permissionAPI.tsx b/src/providers/pond/permissionAPI.tsx index 2311afd..91b7d6a 100644 --- a/src/providers/pond/permissionAPI.tsx +++ b/src/providers/pond/permissionAPI.tsx @@ -135,7 +135,6 @@ export default function PermissionProvider(props: PropsWithChildren) { let url = pondURL("/" + scope.kind + "s/" + scope.key + "/shareByKey") if (as) url = pondURL(`/${scope.kind}s/${scope.key}/shareByKey?as=${as}`) return new Promise((resolve, reject) => { - post(url, { key: key, permissions: permissions.map(permission => permissionToString(permission)) diff --git a/src/tasks/TaskViewer.tsx b/src/tasks/TaskViewer.tsx index 68ebf43..4cc5d48 100644 --- a/src/tasks/TaskViewer.tsx +++ b/src/tasks/TaskViewer.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { EventClickArg } from "@fullcalendar/core"; import { DateClickArg } from "@fullcalendar/interaction"; import { useGlobalState } from "providers"; -import { useMobile, useSnackbar, useThemeType } from "hooks"; +import { useMobile, useSnackbar } from "hooks"; import { useTaskAPI } from "providers"; import { useEffect, useCallback } from "react"; import TaskCalendar from "./TaskCalendar"; @@ -12,7 +12,6 @@ import { Button, Fab, Grid2 as Grid, - IconButton, LinearProgress, Theme, Typography @@ -21,8 +20,6 @@ import { Task } from "models"; import TaskSettings from "./TaskSettings"; import { DayPicker } from "react-day-picker"; 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 TaskDrawer from "./TaskDrawer"; import moment from "moment"; @@ -33,6 +30,7 @@ interface ViewProps { label?: string; drawerView?: boolean; loadKeys?: string[]; //if you want to load tasks for a specific object(s) + overlayButton?: boolean; } interface TabPanelProps { @@ -42,17 +40,7 @@ interface TabPanelProps { } const useStyles = makeStyles((theme: Theme) => { - const themeType = useThemeType(); - 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: { color: theme.palette.getContrastText(theme.palette.secondary.main), backgroundColor: theme.palette.secondary.main, @@ -64,6 +52,22 @@ const useStyles = makeStyles((theme: Theme) => { } }, 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, background: theme.palette.primary.main, "&:hover": { @@ -83,19 +87,19 @@ const useStyles = makeStyles((theme: Theme) => { }); export default function TaskViewer(props: ViewProps) { - const { objectKey, label, drawerView, loadKeys } = props; - const [{ user }] = useGlobalState(); - const [{ as }] = useGlobalState(); + const { objectKey, label, drawerView, loadKeys, overlayButton } = props; + const [{ user, as }] = useGlobalState(); + //const [{ as }] = useGlobalState(); const taskAPI = useTaskAPI(); const { openSnack } = useSnackbar(); - const [tasks, setTasks] = useState>(new Map()); - const [viewTask, setViewTask] = useState(Task.create()); + const [tasks, setTasks] = useState>(new Map([])); + const [viewTask, setViewTask] = useState(); const [newTaskDialog, setNewTaskDialog] = useState(false); const [editTask, setEditTask] = useState(); const [loaded, setLoaded] = useState(false); const [currentDate, setCurrentDate] = useState(new Date()); const isMobile = useMobile(); - const [value, setValue] = React.useState(0); + const [value, setValue] = useState(0); const classes = useStyles(); const [expand, setExpand] = useState(false); const [hideCal, setHideCal] = useState(false); @@ -146,11 +150,13 @@ export default function TaskViewer(props: ViewProps) { taskAPI .getMultiTasks(loadKeys, as) .then(resp => { - resp.data.tasks.forEach(task => { - if (task.settings) { - temp.set(task.key, Task.any(task)); - } - }); + if(resp.data.tasks){ + resp.data.tasks.forEach(task => { + if (task.settings) { + temp.set(task.key, Task.any(task)); + } + }); + } setTasks(temp); setLoaded(true); }) @@ -167,15 +173,18 @@ export default function TaskViewer(props: ViewProps) { taskAPI .listTasks(50, 0, "asc", "start", undefined, undefined, as, prevMonth, nextMonth) .then(resp => { - resp.data.tasks.forEach(task => { - if (task.settings) { - temp.set(task.key, Task.any(task)); - } - }); + if(resp.data.tasks){ + resp.data.tasks.forEach(task => { + if (task.settings) { + temp.set(task.key, Task.any(task)); + } + }); + } setTasks(temp); setLoaded(true); }) .catch(err => { + console.log(err) openSnack("Failed to load tasks"); }); }, [taskAPI, as, user, openSnack, nextMonth, prevMonth]); @@ -216,33 +225,6 @@ export default function TaskViewer(props: ViewProps) { }); }; - const tabIcons = () => { - return ( - - setValue(0)}> - - - setValue(1)}> - - - - ); - }; - function TabPanelMine(props: TabPanelProps) { const { children, value, index, ...other } = props; @@ -266,15 +248,7 @@ export default function TaskViewer(props: ViewProps) { return ( {isMobile || drawerView ? ( - - - - - {!drawerView && tabIcons()} + openSelectedTask(taskId)} /> + + + ) : ( @@ -436,7 +417,7 @@ export default function TaskViewer(props: ViewProps) { }; return ( - + {!loaded ? : viewer()} - { - setOpenDrawer(false)} - completeTask={markComplete} - deleteTask={deleteTask} - editTask={setTaskToEdit} - /> + {viewTask && + setOpenDrawer(false)} + completeTask={markComplete} + deleteTask={deleteTask} + editTask={setTaskToEdit} + /> } );