From fd697666e07b58bc1ecc111f105d6322ff592f44 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 24 Oct 2025 12:08:05 +0200 Subject: [PATCH 01/91] added farm calendar activity type interface --- src/models/FarmCalendarActivityType.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/models/FarmCalendarActivityType.ts diff --git a/src/models/FarmCalendarActivityType.ts b/src/models/FarmCalendarActivityType.ts new file mode 100644 index 0000000..c7f5776 --- /dev/null +++ b/src/models/FarmCalendarActivityType.ts @@ -0,0 +1,10 @@ +export interface FarmCalendarActivityTypeModel { + id: string; + description: string; + name: string; + category: string; + background_color: string; + border_color: string; + text_color: string; + activity_endpoint: string; +} \ No newline at end of file From 9eac2c9e93e5e3995a3b25fe8f93d3230f0742f9 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 24 Oct 2025 12:34:44 +0200 Subject: [PATCH 02/91] adjusted some fields in farm calendar activity types --- src/models/FarmCalendarActivityType.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/models/FarmCalendarActivityType.ts b/src/models/FarmCalendarActivityType.ts index c7f5776..ef822f0 100644 --- a/src/models/FarmCalendarActivityType.ts +++ b/src/models/FarmCalendarActivityType.ts @@ -1,5 +1,6 @@ export interface FarmCalendarActivityTypeModel { - id: string; + '@id': string; + '@type': string; description: string; name: string; category: string; From ea56ec0b57822f0867b999d8695b32137cf15e4d Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 24 Oct 2025 13:11:27 +0200 Subject: [PATCH 03/91] impl agricultural machine interface and used it in UAV flight forecast component --- .../services/WeatherData/UAVFlightsForecast.tsx | 3 ++- src/models/AgriculturalMachine.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 src/models/AgriculturalMachine.ts diff --git a/src/components/dashboard/services/WeatherData/UAVFlightsForecast.tsx b/src/components/dashboard/services/WeatherData/UAVFlightsForecast.tsx index fff6ebe..622f83d 100644 --- a/src/components/dashboard/services/WeatherData/UAVFlightsForecast.tsx +++ b/src/components/dashboard/services/WeatherData/UAVFlightsForecast.tsx @@ -9,6 +9,7 @@ import dayjs from "dayjs"; import { Fragment, useEffect, useState } from "react"; import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import { AgriculturalMachine } from "@models/AgriculturalMachine"; interface ModelData { status?: UAVStatus @@ -58,7 +59,7 @@ const UAVFlightsForecast = () => { } ); - const { fetchData: agriMachineFetchData, loading: agriMachinesLoading, response: agriMachinesResponse, error: agriMachinesError } = useFetch( + const { fetchData: agriMachineFetchData, loading: agriMachinesLoading, response: agriMachinesResponse, error: agriMachinesError } = useFetch( `proxy/farmcalendar/api/v1/AgriculturalMachines/?format=json`, { method: 'GET', diff --git a/src/models/AgriculturalMachine.ts b/src/models/AgriculturalMachine.ts new file mode 100644 index 0000000..3e0e0c2 --- /dev/null +++ b/src/models/AgriculturalMachine.ts @@ -0,0 +1,14 @@ +export interface AgriculturalMachine { + id: string; + name: string; + description: string; + hasAgriParcel: string; + purchase_date: string; + manufacturer: string; + model: string; + seria_number: string; + status: number; + invalidatedAtTime: string; + dateCreated: string; + dateModified: string; +} \ No newline at end of file From 8901ab8347de24a8de3b539372780bed1f970b03 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Mon, 27 Oct 2025 15:52:18 +0100 Subject: [PATCH 04/91] farm calendar fetches activity types to get API info --- .../services/FarmCalendar/FarmCalendar.tsx | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx b/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx index 0cb48b3..f9aa564 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx @@ -1,7 +1,7 @@ import ParcelSelectionModule from "@components/dashboard/ParcelSelectionModule/ParcelSelectionModule"; import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; import useSnackbar from "@hooks/useSnackbar"; -// import { Box, Button } from "@mui/material"; +import { Box, Button } from "@mui/material"; import { useEffect, useMemo, useState } from "react"; import { EventInput } from '@fullcalendar/core'; import useFetch from "@hooks/useFetch"; @@ -11,12 +11,15 @@ import StyledFullCalendar from "@components/shared/styled/StyledFullCalendar/Sty import dayjs from "dayjs"; import { useSession } from "@contexts/SessionContext"; import ContentGuard from "@components/shared/ContentGuard/ContentGuard"; +import { FarmCalendarActivityTypeModel } from "@models/FarmCalendarActivityType"; const FarmCalendarPage = () => { const navigate = useNavigate(); const [dateRange, setDateRange] = useState<{ start: string | null, end: string | null }>({ start: null, end: null }); + const [activityTypes, setActivityTypes] = useState([]); + const { session } = useSession(); const { fetchData, response, error } = useFetch( @@ -26,8 +29,31 @@ const FarmCalendarPage = () => { } ); + const { fetchData: activityTypesFetchData, response: activityTypesResponse, error: activityTypesError } = useFetch( + `proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/?format=json`, + { + method: 'GET', + } + ); + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + useEffect(() => { + activityTypesFetchData(); + }, []) + + useEffect(() => { + if (activityTypesResponse) { + setActivityTypes(activityTypesResponse); + } + }, [activityTypesResponse]) + + useEffect(() => { + if (activityTypesError) { + showSnackbar('error', 'Error loading activity types'); + } + }, [activityTypesError]) + useEffect(() => { if (dateRange.start && dateRange.end && session?.farm_parcel) { fetchData(); @@ -61,14 +87,15 @@ const FarmCalendarPage = () => { <> - {/* + - */} + { - navigate(`edit-activity/${info.event.id.split(":")[3]}`) + const api = activityTypes.find(a => { return a["@id"].split(":")[3] === info.event.extendedProps.activityType["@id"].split(":")[3] })?.activity_endpoint + navigate(`edit-activity/${info.event.id.split(":")[3]}`, { state: { api: api } }) } } onDateRangeChange={setDateRange} From 18f1f00f536d80c30dddc5d1746e6d94a99cf44f Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Mon, 27 Oct 2025 17:05:24 +0100 Subject: [PATCH 05/91] added multiple select functionality to generic select component --- .../shared/GenericSelect/GenericSelect.tsx | 70 +++++++++++++++---- .../GenericSelect/GenericSelect.types.ts | 26 +++++-- 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/src/components/shared/GenericSelect/GenericSelect.tsx b/src/components/shared/GenericSelect/GenericSelect.tsx index 808cf72..b96c80b 100644 --- a/src/components/shared/GenericSelect/GenericSelect.tsx +++ b/src/components/shared/GenericSelect/GenericSelect.tsx @@ -1,5 +1,5 @@ -import { Box, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent } from '@mui/material'; -import { useEffect } from 'react'; +import { Box, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, Chip } from '@mui/material'; +import { useEffect, useMemo } from 'react'; import { GenericSelectProps } from './GenericSelect.types'; import useFetch from '@hooks/useFetch'; @@ -11,21 +11,42 @@ const GenericSelect = ({ selectedValue, setSelectedValue, transformResponse, + multiple = false, }: GenericSelectProps) => { const { fetchData, response, loading } = useFetch(endpoint, { method: 'GET' }); - const handleChange = (event: SelectChangeEvent) => { - setSelectedValue(event.target.value as string); + const handleChange = (event: SelectChangeEvent) => { + const { target: { value } } = event; + setSelectedValue(value as any); }; useEffect(() => { fetchData(); }, []); - const items = response && transformResponse ? transformResponse(response) : response || []; + const items = useMemo(() => + response && transformResponse ? transformResponse(response) : (response || []) as T[] + , [response, transformResponse]); - const isValueValid = Array.isArray(items) && items.some(item => getOptionValue(item) === selectedValue); - const valueToRender = loading || !isValueValid ? '' : selectedValue; + const valueToRender = useMemo(() => { + if (loading) { + return multiple ? [] : ''; + } + + if (multiple) { + const selected = selectedValue as string[] | undefined ?? []; + if (items.length === 0 && selected.length > 0) return []; + const validValues = selected.filter(val => + items.some(item => getOptionValue(item) === val) + ); + return validValues; + } else { + const selected = selectedValue as string | undefined ?? ''; + if (items.length === 0 && selected) return ''; + const isValid = items.some(item => getOptionValue(item) === selected); + return isValid ? selected : ''; + } + }, [loading, multiple, selectedValue, items, getOptionValue]); return ( @@ -37,16 +58,35 @@ const GenericSelect = ({ label={label} disabled={loading} onChange={handleChange} + multiple={multiple} + renderValue={multiple ? (selected) => ( + + {(selected as string[]).map((value) => { + const item = Array.isArray(items) + ? items.find(i => getOptionValue(i) === value) + : null; + const itemLabel = item ? getOptionLabel(item) : value; + return ; + })} + + ) : undefined} > - - Please select an option - + {!multiple && ( + + Please select an option + + )} + {Array.isArray(items) && - items.map((item) => ( - - {getOptionLabel(item)} - - ))} + items.map((item, index) => { + const itemValue = getOptionValue(item); + const key = `${itemValue}-${index}`; + return ( + + {getOptionLabel(item)} + + ); + })} diff --git a/src/components/shared/GenericSelect/GenericSelect.types.ts b/src/components/shared/GenericSelect/GenericSelect.types.ts index 98b6d3a..1f82ae0 100644 --- a/src/components/shared/GenericSelect/GenericSelect.types.ts +++ b/src/components/shared/GenericSelect/GenericSelect.types.ts @@ -1,9 +1,25 @@ -export interface GenericSelectProps { +interface GenericSelectPropsBase { endpoint: string; label: string; - getOptionLabel: (item: T) => string; // Function to get the display label for each item - getOptionValue: (item: T) => string | number; // Function to get the value for each item + getOptionLabel: (item: T) => string; + getOptionValue: (item: T) => string; + transformResponse?: (response: R) => T[]; +} + +// Props for a SINGLE select (multiple is false or undefined) +type GenericSelectPropsSingle = GenericSelectPropsBase & { + multiple?: false; selectedValue: string | undefined; setSelectedValue: React.Dispatch>; - transformResponse?: (response: R) => T[]; // Function to transform the API response -} \ No newline at end of file +}; + +// Props for a MULTI select (multiple is true) +type GenericSelectPropsMultiple = GenericSelectPropsBase & { + multiple: true; + selectedValue: string[] | undefined; + setSelectedValue: React.Dispatch>; +}; + +export type GenericSelectProps = + | GenericSelectPropsSingle + | GenericSelectPropsMultiple; \ No newline at end of file From 40e57498b5c65baa5de0bd93fd189ec84f404fc3 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Mon, 27 Oct 2025 17:05:55 +0100 Subject: [PATCH 06/91] updated agri machine model to match response shape --- src/models/AgriculturalMachine.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/models/AgriculturalMachine.ts b/src/models/AgriculturalMachine.ts index 3e0e0c2..82858fe 100644 --- a/src/models/AgriculturalMachine.ts +++ b/src/models/AgriculturalMachine.ts @@ -1,8 +1,12 @@ export interface AgriculturalMachine { - id: string; + "@id": string; + "@type": string; name: string; description: string; - hasAgriParcel: string; + hasAgriParcel: { + "@id": string; + "@type": string; + }; purchase_date: string; manufacturer: string; model: string; From a6c4cac8660df0cbbe7df05af630011fdedd9584 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Mon, 27 Oct 2025 17:07:11 +0100 Subject: [PATCH 07/91] impl interfaces for all calendar activities into one file, extracted all shared fields into base model --- src/models/FarmCalendarActivities.ts | 338 +++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 src/models/FarmCalendarActivities.ts diff --git a/src/models/FarmCalendarActivities.ts b/src/models/FarmCalendarActivities.ts new file mode 100644 index 0000000..6d5494a --- /dev/null +++ b/src/models/FarmCalendarActivities.ts @@ -0,0 +1,338 @@ +export interface BaseActivityModel { + "@id": string; + "@type": string; + activityType: { + "@id": string; + "@type": string; + }; + title: string; + details: string; +} + +export interface AddRawMaterialOperationModel extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + operatedOn: { + "@id": string; + "@type": string; + }; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; + hasCompostMaterial: { + "@id": string; + "@type": string; + typeName: string; + quantityValue: { + "@id": string; + "@type": string; + unit: string; + numericValue: number; + }; + }[]; +} + +export interface CompostOperationModel extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + isOperatedOn: { + "@id": string; + "@type": string; + }; + hasNestedOperation: { + "@id": string; + "@type": string; + }[]; + hasMeasurement: { + "@id": string; + "@type": string; + }[]; +} + +export interface CompostTurningOperationModel extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + operatedOn: { + "@id": string; + "@type": string; + }; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; +} + +export interface CropGrowthStageObservationModel extends BaseActivityModel { + phenomenonTime: string; + hasEndDatetime: string; + madeBySensor: { + "@id": string; + "@type": string; + name: string; + }; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + hasAgriCrop: { + "@id": string; + "@type": string; + }; + hasResult: { + "@id": string; + "@type": string; + unit: string; + hasValue: string; + }; + observedProperty: string; +} + +export interface CropProtectionOperationModel extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; + hasAppliedAmount: { + "@id": string; + "@type": string; + unit: string; + numericValue: number; + }; + usesPesticide: { + "@id": string; + "@type": string; + }; + operatedOn: { + "@id": string; + "@type": string; + }; +} + +export interface CropStressIndicatorObservationModel extends BaseActivityModel { + phenomenonTime: string; + hasEndDatetime: string; + madeBySensor: { + "@id": string; + "@type": string; + name: string; + }; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + hasAgriCrop: { + "@id": string; + "@type": string; + }; + hasResult: { + "@id": string; + "@type": string; + unit: string; + hasValue: string; + }; + observedProperty: string; +} + +export interface DiseaseDetectionModel extends BaseActivityModel { + phenomenonTime: string; + hasEndDatetime: string; + madeBySensor: { + "@id": string; + "@type": string; + name: string; + }; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + hasArea: string; + hasResult: { + "@id": string; + "@type": string; + unit: string; + hasValue: string; + }; + observedProperty: string; +} + +export interface FertilizationOperationModel extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; + hasAppliedAmount: { + "@id": string; + "@type": string; + unit: string; + numericValue: number; + }; + hasApplicationMethod: string; + usesFertilizer: { + "@id": string; + "@type": string; + }; + operatedOn: { + "@id": string; + "@type": string; + }; +} + +export interface GenericActivityModel extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; +} + +export interface GenericAlertModel extends BaseActivityModel { + severity: string; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + validFrom: string; + validTo: string; + dateIssued: string; + quantityValue: {}; + relatedObservation: { + "@id": string; + "@type": string; + }; +} + +export interface GenericObservationModel extends BaseActivityModel { + phenomenonTime: string; + hasEndDatetime: string; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + madeBySensor: { + "@id": string; + "@type": string; + name: string; + }; + hasResult: { + "@id": string; + "@type": string; + unit: string; + hasValue: string; + }; + observedProperty: string; +} + +export interface IrrigationOperationModel extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; + hasAppliedAmount: { + "@id": string; + "@type": string; + unit: string; + numericValue: number; + }; + usesIrrigationSystem: string; + operatedOn: { + "@id": string; + "@type": string; + }; +} + +export interface SprayingRecommendationModel extends BaseActivityModel { + phenomenonTime: string; + hasEndDatetime: string; + madeBySensor: { + "@id": string; + "@type": string; + name: string; + }; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + hasArea: string; + hasResult: { + "@id": string; + "@type": string; + unit: string; + hasValue: string; + }; + observedProperty: string; + usesPesticide: { + "@id": string; + "@type": string; + }; +} + +export interface VigorEstimationModel extends BaseActivityModel { + phenomenonTime: string; + hasEndDatetime: string; + madeBySensor: { + "@id": string; + "@type": string; + name: string; + }; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + hasArea: string; + hasResult: { + "@id": string; + "@type": string; + unit: string; + hasValue: string; + }; + observedProperty: string; +} + +export interface YieldPredictionModel extends BaseActivityModel { + phenomenonTime: string; + hasEndDatetime: string; + madeBySensor: { + "@id": string; + "@type": string; + name: string; + }; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + hasArea: string; + hasResult: { + "@id": string; + "@type": string; + unit: string; + hasValue: string; + }; + observedProperty: string; +} \ No newline at end of file From a1ffab0871787a0168360ccb178780c7a1e62e95 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Mon, 27 Oct 2025 17:07:25 +0100 Subject: [PATCH 08/91] impl farm crop model --- src/models/FarmCrop.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/models/FarmCrop.ts diff --git a/src/models/FarmCrop.ts b/src/models/FarmCrop.ts new file mode 100644 index 0000000..5ec4942 --- /dev/null +++ b/src/models/FarmCrop.ts @@ -0,0 +1,21 @@ +export interface FarmCropModel { + '@id': string; + '@type': string; + cropSpecies: { + "@id": string; + "@type": string; + "name": string; + "variety": string; + }; + status: number; + invalidatedAtTime: string; + dateCreated: string; + dateModified: string; + name: string; + description: string; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + growth_stage: string; +} \ No newline at end of file From 86ed8343809c479db0407f5a0053fc27ef7a41c4 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Mon, 27 Oct 2025 17:11:45 +0100 Subject: [PATCH 09/91] impl dynamic farm calendar activity CRUD actions component (still WIP), edit farm activity component redone --- .../ActivityDynamicCRUDActions.ts | 9 + .../ActivityDynamicCRUDActions.types.tsx | 375 ++++++++++++++++++ .../FarmCalendarActivities/EditActivity.tsx | 275 ++++++------- 3 files changed, 495 insertions(+), 164 deletions(-) create mode 100644 src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.ts create mode 100644 src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.tsx diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.ts b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.ts new file mode 100644 index 0000000..68f0ae5 --- /dev/null +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.ts @@ -0,0 +1,9 @@ +import { BaseActivityModel } from "@models/FarmCalendarActivities"; + +export interface ActivityDynamicCRUDActionsProps { + activity: T; + onSave?: (activityToSave: BaseActivityModel) => void; + onAdd?: (activityToSave: BaseActivityModel) => void; + onDelete?: () => void; + loading: boolean; +} \ No newline at end of file diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.tsx new file mode 100644 index 0000000..7d7f446 --- /dev/null +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.tsx @@ -0,0 +1,375 @@ +import { BaseActivityModel } from "@models/FarmCalendarActivities"; +import { ActivityDynamicCRUDActionsProps } from "./ActivityDynamicCRUDActions"; +import { Box, Button, Card, CardContent, Stack, TextField } from "@mui/material"; +import dayjs, { Dayjs } from "dayjs"; +import { useEffect, useState } from "react"; +import { DateTimePicker } from "@mui/x-date-pickers"; +import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; +import { FarmParcelModel } from "@models/FarmParcel"; +import { FarmCropModel } from "@models/FarmCrop"; + +import AddIcon from '@mui/icons-material/Add'; +import SaveIcon from '@mui/icons-material/Save'; +import DeleteIcon from '@mui/icons-material/Delete'; +import useDialog from "@hooks/useDialog"; +import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; +import { AgriculturalMachine } from "@models/AgriculturalMachine"; + +const ActivityDynamicCRUDActions = ({ activity, onAdd, onDelete, onSave, loading }: ActivityDynamicCRUDActionsProps) => { + + const [formData, setFormData] = useState(activity); + const [selectedParcel, setSelectedParcel] = useState(''); + const [selectedAgriCrop, setSelectedAgriCrop] = useState(''); + const [selectedAgriMachines, setSelectedAgriMachines] = useState([]); + + useEffect(() => { + let parcelID: string | undefined; + if ('hasAgriParcel' in formData) { + parcelID = (formData as any).hasAgriParcel["@id"]; + } else if ('operatedOn' in formData) { + parcelID = (formData as any).operatedOn["@id"]; + } + if (parcelID) { + const idParts = parcelID.split(':'); + setSelectedParcel(idParts[idParts.length - 1]); + } + + let cropID: string | undefined; + if ('hasAgriCrop' in formData) { + cropID = (formData as any).hasAgriCrop["@id"]; + } + if (cropID) { + const idParts = cropID.split(':'); + setSelectedAgriCrop(idParts[idParts.length - 1]); + } + + let agriMachinesIDs: string[] | undefined + if ('usesAgriculturalMachinery' in formData) { + agriMachinesIDs = (formData as any).usesAgriculturalMachinery.map((m: any) => { return m["@id"].split(':')[3] }); + } + if (agriMachinesIDs) { + setSelectedAgriMachines(agriMachinesIDs); + } + }, [formData]); + + const { dialogProps, showDialog } = useDialog(); + + const handleCloseDialog = () => { + dialogProps.onClose(); + }; + + /** Field change handlers start */ + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + const keys = name.split('.'); + + setFormData(prev => { + const newState = JSON.parse(JSON.stringify(prev)) as T; + + let currentLevel: any = newState; + try { + for (let i = 0; i < keys.length - 1; i++) { + currentLevel = currentLevel[keys[i]]; + } + const finalKey = keys[keys.length - 1]; + const isNumeric = typeof currentLevel[finalKey] === 'number'; + currentLevel[finalKey] = isNumeric ? parseFloat(value) || 0 : value; + + } catch (error) { + console.error(`Error setting nested property "${name}":`, error); + return prev; + } + return newState; + }); + }; + + const handleDateChange = (newValue: Dayjs | null, fieldName: string) => { + const formattedValue = newValue ? newValue.toISOString() : null; + setFormData(prev => ({ ...prev, [fieldName]: formattedValue } as T)); + }; + /** Field change handlers end */ + + /** -------------------------------------------------------------------------- */ + + /** Field rendering helpers start */ + const renderDateFields = () => { + return ( + <> + {'hasStartDatetime' in formData && ( + handleDateChange(val, 'hasStartDatetime')} + /> + )} + {'phenomenonTime' in formData && ( + handleDateChange(val, 'phenomenonTime')} + /> + )} + {'validFrom' in formData && ( + handleDateChange(val, 'validFrom')} + /> + )} + {'hasEndDatetime' in formData && ( + handleDateChange(val, 'hasEndDatetime')} + /> + )} + {'validTo' in formData && ( + handleDateChange(val, 'validTo')} + /> + )} + + ) + } + + const renderAgentAndMachinery = () => { + return ( + <> + {'responsibleAgent' in formData && ( + + )} + {'usesAgriculturalMachinery' in formData && ( + + multiple={true} + endpoint='proxy/farmcalendar/api/v1/AgriculturalMachines/?format=json' + label='Agricultural machines' + getOptionLabel={item => `${item.name} (${item.model})`} + getOptionValue={item => item["@id"].split(':')[3]} + setSelectedValue={setSelectedAgriMachines} + selectedValue={selectedAgriMachines} + /> + )} + + ) + } + + const renderPartOfX = () => { + return ( + <> + ) + } + + const renderSensorResultAndObservedProperty = () => { + type SensorShape = { name: string }; + type ResultShape = { unit: string; hasValue: string }; + + return ( + <> + {'madeBySensor' in formData && ( + + )} + + {'hasResult' in formData && ( + + )} + {'hasResult' in formData && ( + + )} + + {'observedProperty' in formData && ( + + )} + + ) + } + + const renderSelectedCrop = () => { + // TODO: filter if parcel is selected + return ( + <> + {'hasAgriCrop' in formData && ( + + endpoint='proxy/farmcalendar/api/v1/FarmCrops/?format=json' + label='Crop' + selectedValue={selectedAgriCrop} + setSelectedValue={setSelectedAgriCrop} + getOptionLabel={item => `${item.name} - ${item.cropSpecies.name} - ${item.growth_stage}`} + getOptionValue={item => item["@id"].split(':')[3]} + /> + )} + + ) + } + + const renderHasArea = () => { + return ( + <> + {'hasArea' in formData && ( + + )} + + ) + } + + const renderCompostOpSpecificFields = () => { + return ( + <> + ) + } + /** Field rendering helpers end */ + + /** -------------------------------------------------------------------------- */ + + /** Button helper functions start */ + const handlePost = () => { + let body = { ...formData }; + if ('hasAgriParcel' in body) { + (body.hasAgriParcel as { '@id': string })['@id'] = `urn:farmcalendar:Parcel:${selectedParcel}`; + } else if ('operatedOn' in body) { + (body.operatedOn as { '@id': string })['@id'] = `urn:farmcalendar:Parcel:${selectedParcel}`; + } + if ('hasAgriCrop' in body) { + (body.hasAgriCrop as { '@id': string })['@id'] = `urn:farmcalendar:FarmCrop:${selectedAgriCrop}`; + } + if ('usesAgriculturalMachinery' in body) { + (body.usesAgriculturalMachinery as string[]) = selectedAgriMachines; + } + onAdd && onAdd(body); + }; + + const handlePatch = () => { + let body = { ...formData }; + if ('hasAgriParcel' in body) { + (body.hasAgriParcel as { '@id': string })['@id'] = `urn:farmcalendar:Parcel:${selectedParcel}`; + } else if ('operatedOn' in body) { + (body.operatedOn as { '@id': string })['@id'] = `urn:farmcalendar:Parcel:${selectedParcel}`; + } + if ('hasAgriCrop' in body) { + (body.hasAgriCrop as { '@id': string })['@id'] = `urn:farmcalendar:FarmCrop:${selectedAgriCrop}`; + } + if ('usesAgriculturalMachinery' in body) { + (body.usesAgriculturalMachinery as string[]) = selectedAgriMachines; + } + console.log(body); + + onSave && onSave(body); + } + + const handleDelete = () => { + onDelete && onDelete(); + }; + /** Button helper functions end */ + + return ( + <> + + + + + {renderDateFields()} + + {renderAgentAndMachinery()} + + endpoint='proxy/farmcalendar/api/v1/FarmParcels/?format=json' + label='Parcel' + selectedValue={selectedParcel} + setSelectedValue={setSelectedParcel} + getOptionLabel={item => `${item.identifier} (${item.category})`} + getOptionValue={item => item["@id"].split(':')[3]} + /> + {renderPartOfX()} + {renderSensorResultAndObservedProperty()} + {renderSelectedCrop()} + {renderHasArea()} + {renderCompostOpSpecificFields()} + + + + + {onAdd && } + {onDelete && } + {onSave && } + + + + ) +} + +export default ActivityDynamicCRUDActions; \ No newline at end of file diff --git a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx index 86a28b1..dd6baf7 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx @@ -1,206 +1,153 @@ -import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; -import useDialog from "@hooks/useDialog"; import useFetch from "@hooks/useFetch"; import useSnackbar from "@hooks/useSnackbar"; -import { FarmCalendarActivityModel } from "@models/FarmCalendarActivity"; -import { FarmParcelModel } from "@models/FarmParcel"; -import { Box, Button, Card, CardContent, Skeleton, Stack, TextField, Typography } from "@mui/material"; -import { DateTimePicker } from "@mui/x-date-pickers"; -import dayjs, { Dayjs } from "dayjs"; +import { Box, Skeleton, Typography } from "@mui/material"; import { useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; - -import SaveIcon from '@mui/icons-material/Save'; -import DeleteIcon from '@mui/icons-material/Delete'; -import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; + +import { + BaseActivityModel, + AddRawMaterialOperationModel, + CompostOperationModel, + CompostTurningOperationModel, + CropGrowthStageObservationModel, + CropProtectionOperationModel, + CropStressIndicatorObservationModel, + DiseaseDetectionModel, + FertilizationOperationModel, + GenericActivityModel, + GenericAlertModel, + GenericObservationModel, + IrrigationOperationModel, + SprayingRecommendationModel, + VigorEstimationModel, + YieldPredictionModel +} from '@models/FarmCalendarActivities'; +import ActivityDynamicCRUDActions from "@components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types"; + +const ActivityFormComponentMap: { [key: string]: React.FC } = { + 'AddRawMaterialOperation': (props) => {...props} />, + 'CompostOperation': (props) => {...props} />, + 'CompostTurningOperation': (props) => {...props} />, + 'CropGrowthStageObservation': (props) => {...props} />, + 'CropProtectionOperation': (props) => {...props} />, + 'CropStressIndicatorObservation': (props) => {...props} />, + 'DiseaseDetection': (props) => {...props} />, + 'FertilizationOperation': (props) => {...props} />, + 'GenericActivity': (props) => {...props} />, + 'Alert': (props) => {...props} />, + 'GenericObservation': (props) => {...props} />, + 'IrrigationOperation': (props) => {...props} />, + 'SprayingRecommendation': (props) => {...props} />, + 'VigorEstimation': (props) => {...props} />, + 'YieldPrediction': (props) => {...props} />, +}; + +interface LocationState { + api: string | undefined; +} const EditCalendarActivityPage = () => { - const [activity, setActivity] = useState(); - const [title, setTitle] = useState(''); - - const [selectedParcel, setSelectedParcel] = useState(''); + const [activityData, setActivityData] = useState(null); + const [pageTitle, setPageTitle] = useState(''); const { id } = useParams(); - const { fetchData, loading, response, error } = useFetch( - `proxy/farmcalendar/api/v1/FarmCalendarActivities/${id}/?format=json`, - { - method: 'GET', - } - ); + const location = useLocation(); + const navigate = useNavigate(); - const { fetchData: editFetchData, response: editResponse, error: editError } = useFetch( - `proxy/farmcalendar/api/v1/FarmCalendarActivities/${id}/?format=json`, - { - method: 'PUT', - } - ); + const state = location.state as LocationState; + const api = state?.api; - const { fetchData: deleteFetchData, error: deleteError } = useFetch( - `proxy/farmcalendar/api/v1/FarmCalendarActivities/${id}/?format=json`, - { - method: 'DELETE', - } + const { fetchData, loading, response, error } = useFetch( + api ? `proxy/farmcalendar${api}${id}/?format=json` : '', + { method: 'GET' } + ); + const { fetchData: patchFetchData, loading: editLoading, response: editResponse, error: editError } = useFetch( + api ? `proxy/farmcalendar${api}${id}/?format=json` : '', + { method: 'PUT' } + ); + const { fetchData: deleteFetchData, loading: deleteLoading, response: deleteResponse, error: deleteError } = useFetch( + api ? `proxy/farmcalendar${api}${id}/?format=json` : '', + { method: 'DELETE' } ); - - const { dialogProps, showDialog } = useDialog(); - - const handleCloseDialog = () => { - dialogProps.onClose(); - }; const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const isMutating = editLoading || deleteLoading; useEffect(() => { - fetchData(); - }, []) - - useEffect(() => { - if (error) { - showSnackbar('error', 'Error loading activity'); + if (api) fetchData(); + else { + showSnackbar('error', 'API path not specified.'); + navigate('/farm-calendar'); } - }, [error]) + }, [api]); useEffect(() => { - if (editError) { - showSnackbar('error', 'Error editing activity'); - } - }, [editError]) - - useEffect(() => { - if (deleteError) { - showSnackbar('error', 'Error deleting activity'); - } - }, [deleteError]) + if (error) showSnackbar('error', 'Error loading activity'); + if (editError) showSnackbar('error', 'Error editing activity'); + if (deleteError) showSnackbar('error', 'Error deleting activity'); + }, [error, editError, deleteError]); useEffect(() => { if (response) { - setActivity(response); - setTitle(response.title); - setSelectedParcel(response.hasAgriParcel["@id"].split(':')[3]); + setActivityData(response); + setPageTitle(`Edit ${response.title}`); } - }, [response]) + }, [response]); - const navigate = useNavigate(); useEffect(() => { if (editResponse) { + showSnackbar('success', 'Activity updated successfully.'); + navigate("/farm-calendar"); + } + if (deleteResponse) { + showSnackbar('success', 'Activity deleted successfully.'); navigate("/farm-calendar"); } - }, [editResponse]) + }, [editResponse, deleteResponse]); - const handleDelete = async () => { - await deleteFetchData(); - navigate("/farm-calendar"); + const handlePatch = (activityToSave: BaseActivityModel) => { + patchFetchData({ body: activityToSave }); }; - const handleEdit = () => { - editFetchData({ - body: { - activityType: activity?.activityType["@id"].split(':')[3], - title: activity?.title, - details: activity?.details, - hasStartDatetime: activity?.hasStartDatetime, - hasEndDatetime: activity?.hasEndDatetime, - hasAgriParcel: selectedParcel, - usesAgriculturalMachinery: [] // TODO: change - // responsibleAgent - } - }); + const handleDelete = () => { + deleteFetchData(); }; + const renderForm = () => { + if (!activityData) return null; + const activityType = activityData['@type']; + const FormComponent = ActivityFormComponentMap[activityType]; - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - // const isNumericField = name === 'base_gdd'; - // const finalValue = isNumericField ? parseFloat(value) : value; - setActivity(prev => prev ? { ...prev, [name]: value } : undefined); - }; + if (!FormComponent) { + return Unknown activity type: {activityType}; + } - const handleDateChange = ( - newValue: Dayjs | null, - fieldName: 'hasStartDatetime' | 'hasEndDatetime' - ) => { - const formattedValue = newValue ? newValue.toISOString() : null; - setActivity(prev => prev ? { ...prev, [fieldName]: formattedValue } : undefined); + return ( + + ); }; - const isFormInvalid = - !activity?.title?.trim() || - !activity.hasStartDatetime || - !activity.hasEndDatetime || - !selectedParcel - return ( <> - {loading && } - { - !(loading || error) && - - Edit {title} - - - - - handleDateChange(newValue, 'hasStartDatetime')} - /> - handleDateChange(newValue, 'hasEndDatetime')} - /> - - {activity && - endpoint='proxy/farmcalendar/api/v1/FarmParcels/?format=json' - label='Has Parcel' - selectedValue={selectedParcel} - setSelectedValue={setSelectedParcel} - getOptionLabel={item => `${item.identifier} (${item.category})`} - getOptionValue={item => item["@id"].split(':')[3]}> - } - {/* Hardcoded */} - - - - {/* Hardcoded */} - - - - - - - - - } - + + {pageTitle} + + {loading && } + + {!loading && activityData && renderForm()} + + {error && !loading && ( + Could not load activity details. + )} + + Date: Tue, 28 Oct 2025 12:20:16 +0100 Subject: [PATCH 10/91] impl operated on compost pile field, post and patch data logic moved to separate function --- .../ActivityDynamicCRUDActions.types.tsx | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.tsx index 7d7f446..b809ccb 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.tsx @@ -21,6 +21,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA const [selectedParcel, setSelectedParcel] = useState(''); const [selectedAgriCrop, setSelectedAgriCrop] = useState(''); const [selectedAgriMachines, setSelectedAgriMachines] = useState([]); + const [operatedOnCompostPile, setOperatedOnCompostPile] = useState(''); useEffect(() => { let parcelID: string | undefined; @@ -43,13 +44,22 @@ const ActivityDynamicCRUDActions = ({ activity, onA setSelectedAgriCrop(idParts[idParts.length - 1]); } - let agriMachinesIDs: string[] | undefined + let agriMachinesIDs: string[] | undefined; if ('usesAgriculturalMachinery' in formData) { agriMachinesIDs = (formData as any).usesAgriculturalMachinery.map((m: any) => { return m["@id"].split(':')[3] }); } if (agriMachinesIDs) { setSelectedAgriMachines(agriMachinesIDs); } + + let operatedOnCompostPile: string | undefined; + if ('isOperatedOn' in formData) { + operatedOnCompostPile = (formData as any).isOperatedOn["@id"]; + } + if (operatedOnCompostPile) { + const idParts = operatedOnCompostPile.split(':'); + setOperatedOnCompostPile(idParts[idParts.length - 1]); + } }, [formData]); const { dialogProps, showDialog } = useDialog(); @@ -87,6 +97,10 @@ const ActivityDynamicCRUDActions = ({ activity, onA const formattedValue = newValue ? newValue.toISOString() : null; setFormData(prev => ({ ...prev, [fieldName]: formattedValue } as T)); }; + + const handleOperatedOnCompostPile = (e: React.ChangeEvent) => { + setOperatedOnCompostPile(e.target.value); + } /** Field change handlers end */ /** -------------------------------------------------------------------------- */ @@ -245,17 +259,26 @@ const ActivityDynamicCRUDActions = ({ activity, onA ) } - const renderCompostOpSpecificFields = () => { + const renderOperatedOnCompostPile = () => { return ( - <> + <> + {'isOperatedOn' in formData && ( + + )} + ) } /** Field rendering helpers end */ /** -------------------------------------------------------------------------- */ - /** Button helper functions start */ - const handlePost = () => { + /** Button handler functions start */ + const prepPostAndPatch = () => { let body = { ...formData }; if ('hasAgriParcel' in body) { (body.hasAgriParcel as { '@id': string })['@id'] = `urn:farmcalendar:Parcel:${selectedParcel}`; @@ -268,22 +291,19 @@ const ActivityDynamicCRUDActions = ({ activity, onA if ('usesAgriculturalMachinery' in body) { (body.usesAgriculturalMachinery as string[]) = selectedAgriMachines; } - onAdd && onAdd(body); + if ('isOperatedOn' in body) { + (body.isOperatedOn as { '@id': string })['@id'] = `urn:farmcalendar:CompostPile:${operatedOnCompostPile}`; + } + return body; + } + + const handlePost = () => { + onAdd && onAdd(prepPostAndPatch()); }; const handlePatch = () => { - let body = { ...formData }; - if ('hasAgriParcel' in body) { - (body.hasAgriParcel as { '@id': string })['@id'] = `urn:farmcalendar:Parcel:${selectedParcel}`; - } else if ('operatedOn' in body) { - (body.operatedOn as { '@id': string })['@id'] = `urn:farmcalendar:Parcel:${selectedParcel}`; - } - if ('hasAgriCrop' in body) { - (body.hasAgriCrop as { '@id': string })['@id'] = `urn:farmcalendar:FarmCrop:${selectedAgriCrop}`; - } - if ('usesAgriculturalMachinery' in body) { - (body.usesAgriculturalMachinery as string[]) = selectedAgriMachines; - } + const body = prepPostAndPatch(); + console.log(body); onSave && onSave(body); @@ -292,7 +312,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA const handleDelete = () => { onDelete && onDelete(); }; - /** Button helper functions end */ + /** Button handler functions end */ return ( <> @@ -322,7 +342,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA {renderSensorResultAndObservedProperty()} {renderSelectedCrop()} {renderHasArea()} - {renderCompostOpSpecificFields()} + {renderOperatedOnCompostPile()} From 4a7f562cd93e46506f34a7ca21e2c7e87a5a2ca8 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 28 Oct 2025 15:48:07 +0100 Subject: [PATCH 11/91] impl WIP nested activities field, changed permuted file names --- ...pes.tsx => ActivityDynamicCRUDActions.tsx} | 36 +++++++++++++++++-- ...ts => ActivityDynamicCRUDActions.types.ts} | 0 2 files changed, 34 insertions(+), 2 deletions(-) rename src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/{ActivityDynamicCRUDActions.types.tsx => ActivityDynamicCRUDActions.tsx} (92%) rename src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/{ActivityDynamicCRUDActions.ts => ActivityDynamicCRUDActions.types.ts} (100%) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx similarity index 92% rename from src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.tsx rename to src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index b809ccb..124ce89 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -1,6 +1,6 @@ import { BaseActivityModel } from "@models/FarmCalendarActivities"; -import { ActivityDynamicCRUDActionsProps } from "./ActivityDynamicCRUDActions"; -import { Box, Button, Card, CardContent, Stack, TextField } from "@mui/material"; +import { ActivityDynamicCRUDActionsProps } from "./ActivityDynamicCRUDActions.types"; +import { Box, Button, Card, CardContent, List, ListItem, ListItemText, Stack, TextField, Typography } from "@mui/material"; import dayjs, { Dayjs } from "dayjs"; import { useEffect, useState } from "react"; import { DateTimePicker } from "@mui/x-date-pickers"; @@ -273,6 +273,37 @@ const ActivityDynamicCRUDActions = ({ activity, onA ) } + + const renderNestedActivities = () => { + // TODO: finish + const nestedActivities = []; + if ('hasMeasurement' in formData) { + nestedActivities.push(...(formData.hasMeasurement as any)); + } + if ('hasNestedOperation' in formData) { + nestedActivities.push(...(formData.hasNestedOperation as any)); + } + return ( + <> + {nestedActivities.length && + + + Nested activities + 10}> + {nestedActivities.map((n: any) => { + return + + + })} + + + + } + + ) + } /** Field rendering helpers end */ /** -------------------------------------------------------------------------- */ @@ -343,6 +374,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA {renderSelectedCrop()} {renderHasArea()} {renderOperatedOnCompostPile()} + {renderNestedActivities()} diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.ts b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.ts similarity index 100% rename from src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.ts rename to src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.ts From 9b1d1da57f158d82890c0b6e1da6b3c2c94d032f Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 28 Oct 2025 15:51:07 +0100 Subject: [PATCH 12/91] label fix --- .../ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index 124ce89..00903f5 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -264,7 +264,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA <> {'isOperatedOn' in formData && ( Date: Tue, 28 Oct 2025 15:51:49 +0100 Subject: [PATCH 13/91] fix import --- .../FarmCalendar/FarmCalendarActivities/EditActivity.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx index dd6baf7..0c463f8 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx @@ -23,7 +23,7 @@ import { VigorEstimationModel, YieldPredictionModel } from '@models/FarmCalendarActivities'; -import ActivityDynamicCRUDActions from "@components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types"; +import ActivityDynamicCRUDActions from "@components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions"; const ActivityFormComponentMap: { [key: string]: React.FC } = { 'AddRawMaterialOperation': (props) => {...props} />, From 6755187ca1791c16eb468a6a79ac59267a7080dd Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 28 Oct 2025 15:53:30 +0100 Subject: [PATCH 14/91] condition for render nested activities fix --- .../ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index 00903f5..0908913 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -285,7 +285,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA } return ( <> - {nestedActivities.length && + {nestedActivities.length > 0 && Nested activities From e2301410417f2173b2813a7dd3f29b559c8964bd Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 28 Oct 2025 17:15:35 +0100 Subject: [PATCH 15/91] impl applied amount and pesticide fields rendering, impl pesticide model --- .../ActivityDynamicCRUDActions.tsx | 63 +++++++++++++++++++ src/models/Pesticide.ts | 15 +++++ 2 files changed, 78 insertions(+) create mode 100644 src/models/Pesticide.ts diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index 0908913..a3fb407 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -14,6 +14,7 @@ import DeleteIcon from '@mui/icons-material/Delete'; import useDialog from "@hooks/useDialog"; import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; import { AgriculturalMachine } from "@models/AgriculturalMachine"; +import { PesticideModel } from "@models/Pesticide"; const ActivityDynamicCRUDActions = ({ activity, onAdd, onDelete, onSave, loading }: ActivityDynamicCRUDActionsProps) => { @@ -22,6 +23,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA const [selectedAgriCrop, setSelectedAgriCrop] = useState(''); const [selectedAgriMachines, setSelectedAgriMachines] = useState([]); const [operatedOnCompostPile, setOperatedOnCompostPile] = useState(''); + const [selectedPesticide, setSelectedPesticide] = useState(''); useEffect(() => { let parcelID: string | undefined; @@ -60,6 +62,15 @@ const ActivityDynamicCRUDActions = ({ activity, onA const idParts = operatedOnCompostPile.split(':'); setOperatedOnCompostPile(idParts[idParts.length - 1]); } + + let pesticideID: string | undefined; + if ('usesPesticide' in formData) { + pesticideID = (formData as any).usesPesticide["@id"]; + } + if (pesticideID) { + const idParts = pesticideID.split(':'); + setSelectedPesticide(idParts[idParts.length - 1]); + } }, [formData]); const { dialogProps, showDialog } = useDialog(); @@ -274,6 +285,53 @@ const ActivityDynamicCRUDActions = ({ activity, onA ) } + const renderAppliedAmount = () => { + type AppliedAmountShape = { unit: string, numericValue: number }; + + return ( + <> + + {'hasAppliedAmount' in formData && ( + + )} + {'hasAppliedAmount' in formData && ( + + )} + + + ) + } + + const renderPesticide = () => { + // TODO: filter if parcel is selected + return ( + <> + {'usesPesticide' in formData && ( + + endpoint='proxy/farmcalendar/api/v1/Pesticides/?format=json' + label='Pesticide' + selectedValue={selectedPesticide} + setSelectedValue={setSelectedPesticide} + getOptionLabel={item => `${item.hasCommercialName} - ${item.hasActiveSubstance} - ${item.hasPreharvestInterval}`} + getOptionValue={item => item["@id"].split(':')[3]} + /> + )} + + ) + } + const renderNestedActivities = () => { // TODO: finish const nestedActivities = []; @@ -325,6 +383,9 @@ const ActivityDynamicCRUDActions = ({ activity, onA if ('isOperatedOn' in body) { (body.isOperatedOn as { '@id': string })['@id'] = `urn:farmcalendar:CompostPile:${operatedOnCompostPile}`; } + if ('usesPesticide' in body) { + (body.usesPesticide as { '@id': string })['@id'] = `urn:farmcalendar:Pesticide:${selectedPesticide}`; + } return body; } @@ -374,6 +435,8 @@ const ActivityDynamicCRUDActions = ({ activity, onA {renderSelectedCrop()} {renderHasArea()} {renderOperatedOnCompostPile()} + {renderAppliedAmount()} + {renderPesticide()} {renderNestedActivities()} diff --git a/src/models/Pesticide.ts b/src/models/Pesticide.ts new file mode 100644 index 0000000..994e882 --- /dev/null +++ b/src/models/Pesticide.ts @@ -0,0 +1,15 @@ +export interface PesticideModel { + "@id": string, + "@type": string + dateCreated: string, + dateModified: string, + hasCommercialName: string, + description: string, + hasCost: string, + isPricePer: string, + hasActiveSubstance: string, + isTargetedTowards: string, + hasPreharvestInterval: number, + status: number, + invalidatedAtTime: string | null, +} \ No newline at end of file From 5ae3dbd6e65268ec3eaa784f68c5ead6b7724d45 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Wed, 29 Oct 2025 14:29:43 +0100 Subject: [PATCH 16/91] impl application method field --- .../ActivityDynamicCRUDActions.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index a3fb407..2e0ac43 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -168,7 +168,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA name="responsibleAgent" value={formData.responsibleAgent ?? ''} onChange={handleChange} - error={!(formData.responsibleAgent as string).trim()} /> + error={!(formData.responsibleAgent ? formData.responsibleAgent as string : '').trim()} /> )} {'usesAgriculturalMachinery' in formData && ( @@ -285,6 +285,21 @@ const ActivityDynamicCRUDActions = ({ activity, onA ) } + const renderApplicationMethod = () => { + return ( + <> + {'hasApplicationMethod' in formData && ( + + )} + + ) + } + const renderAppliedAmount = () => { type AppliedAmountShape = { unit: string, numericValue: number }; @@ -436,6 +451,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA {renderHasArea()} {renderOperatedOnCompostPile()} {renderAppliedAmount()} + {renderApplicationMethod()} {renderPesticide()} {renderNestedActivities()} From 378c8d803c85c0178ac25232d8734b2c6a61905f Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Wed, 29 Oct 2025 14:36:45 +0100 Subject: [PATCH 17/91] impl fertilizer field and interface --- .../ActivityDynamicCRUDActions.tsx | 33 +++++++++++++++++++ src/models/Fertilizer.ts | 15 +++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/models/Fertilizer.ts diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index 2e0ac43..a355053 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -15,6 +15,7 @@ import useDialog from "@hooks/useDialog"; import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; import { AgriculturalMachine } from "@models/AgriculturalMachine"; import { PesticideModel } from "@models/Pesticide"; +import { FertilizerModel } from "@models/Fertilizer"; const ActivityDynamicCRUDActions = ({ activity, onAdd, onDelete, onSave, loading }: ActivityDynamicCRUDActionsProps) => { @@ -24,6 +25,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA const [selectedAgriMachines, setSelectedAgriMachines] = useState([]); const [operatedOnCompostPile, setOperatedOnCompostPile] = useState(''); const [selectedPesticide, setSelectedPesticide] = useState(''); + const [selectedFertilizer, setSelectedFertilizer] = useState(''); useEffect(() => { let parcelID: string | undefined; @@ -71,6 +73,15 @@ const ActivityDynamicCRUDActions = ({ activity, onA const idParts = pesticideID.split(':'); setSelectedPesticide(idParts[idParts.length - 1]); } + + let fertilizerID: string | undefined; + if ('usesFertilizer' in formData) { + fertilizerID = (formData as any).usesFertilizer["@id"]; + } + if (fertilizerID) { + const idParts = fertilizerID.split(':'); + setSelectedFertilizer(idParts[idParts.length - 1]); + } }, [formData]); const { dialogProps, showDialog } = useDialog(); @@ -347,6 +358,24 @@ const ActivityDynamicCRUDActions = ({ activity, onA ) } + const renderFertilizer = () => { + // TODO: filter if parcel is selected + return ( + <> + {'usesFertilizer' in formData && ( + + endpoint='proxy/farmcalendar/api/v1/Fertilizers/?format=json' + label='Fertilizer' + selectedValue={selectedFertilizer} + setSelectedValue={setSelectedFertilizer} + getOptionLabel={item => `${item.hasCommercialName} - ${item.hasActiveSubstance} - ${item.hasNutrientConcentration}`} + getOptionValue={item => item["@id"].split(':')[3]} + /> + )} + + ) + } + const renderNestedActivities = () => { // TODO: finish const nestedActivities = []; @@ -401,6 +430,9 @@ const ActivityDynamicCRUDActions = ({ activity, onA if ('usesPesticide' in body) { (body.usesPesticide as { '@id': string })['@id'] = `urn:farmcalendar:Pesticide:${selectedPesticide}`; } + if ('usesFertilizer' in body) { + (body.usesFertilizer as { '@id': string })['@id'] = `urn:farmcalendar:Fertilizer:${selectedFertilizer}`; + } return body; } @@ -453,6 +485,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA {renderAppliedAmount()} {renderApplicationMethod()} {renderPesticide()} + {renderFertilizer()} {renderNestedActivities()} diff --git a/src/models/Fertilizer.ts b/src/models/Fertilizer.ts new file mode 100644 index 0000000..c0d83ce --- /dev/null +++ b/src/models/Fertilizer.ts @@ -0,0 +1,15 @@ +export interface FertilizerModel { + "@id": string; + "@type": string; + hasCommercialName: string; + description: string; + hasCost: string; + isPricePer: string; + hasActiveSubstance: string; + isTargetedTowards: string; + hasNutrientConcentration: string; + status: number; + dateCreated: string; + dateModified: string; + invalidatedAtTime: string; +} \ No newline at end of file From 20357a1177607f60a240559ae1f7331ec1400009 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Wed, 29 Oct 2025 14:41:00 +0100 Subject: [PATCH 18/91] fix for generic activity and observation in the edit activity component --- .../FarmCalendar/FarmCalendarActivities/EditActivity.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx index 0c463f8..9cd7740 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx @@ -34,9 +34,9 @@ const ActivityFormComponentMap: { [key: string]: React.FC } = { 'CropStressIndicatorObservation': (props) => {...props} />, 'DiseaseDetection': (props) => {...props} />, 'FertilizationOperation': (props) => {...props} />, - 'GenericActivity': (props) => {...props} />, + 'FarmCalendarActivity': (props) => {...props} />, 'Alert': (props) => {...props} />, - 'GenericObservation': (props) => {...props} />, + 'Observation': (props) => {...props} />, 'IrrigationOperation': (props) => {...props} />, 'SprayingRecommendation': (props) => {...props} />, 'VigorEstimation': (props) => {...props} />, From 1711b452ca95b03359b19179d596a8a0b8f56faa Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Wed, 29 Oct 2025 16:31:40 +0100 Subject: [PATCH 19/91] impl compost material operations for add raw material activity type. Adjusted order of code execution in some render functions --- .../ActivityDynamicCRUDActions.tsx | 148 ++++++++++++++++-- 1 file changed, 134 insertions(+), 14 deletions(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index a355053..c3c295b 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -1,6 +1,6 @@ -import { BaseActivityModel } from "@models/FarmCalendarActivities"; +import { AddRawMaterialOperationModel, BaseActivityModel } from "@models/FarmCalendarActivities"; import { ActivityDynamicCRUDActionsProps } from "./ActivityDynamicCRUDActions.types"; -import { Box, Button, Card, CardContent, List, ListItem, ListItemText, Stack, TextField, Typography } from "@mui/material"; +import { Box, Button, Card, CardContent, IconButton, List, ListItem, ListItemText, Stack, TextField, Typography } from "@mui/material"; import dayjs, { Dayjs } from "dayjs"; import { useEffect, useState } from "react"; import { DateTimePicker } from "@mui/x-date-pickers"; @@ -17,6 +17,8 @@ import { AgriculturalMachine } from "@models/AgriculturalMachine"; import { PesticideModel } from "@models/Pesticide"; import { FertilizerModel } from "@models/Fertilizer"; +import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; + const ActivityDynamicCRUDActions = ({ activity, onAdd, onDelete, onSave, loading }: ActivityDynamicCRUDActionsProps) => { const [formData, setFormData] = useState(activity); @@ -123,6 +125,73 @@ const ActivityDynamicCRUDActions = ({ activity, onA const handleOperatedOnCompostPile = (e: React.ChangeEvent) => { setOperatedOnCompostPile(e.target.value); } + + const handleAddCompostMaterial = () => { + setFormData(prev => { + if (!prev || !('hasCompostMaterial' in prev)) return prev; + + const newState = JSON.parse(JSON.stringify(prev)) as T & AddRawMaterialOperationModel; + const newMaterial = { + "@id": Date.now().toString(), + "@type": '', + typeName: '', + quantityValue: { + "@id": '', + "@type": '', + unit: '', + numericValue: 0 + } + }; + newState.hasCompostMaterial.push(newMaterial); + + return newState; + }); + }; + + const handleRemoveCompostMaterial = (itemId: string) => () => { + setFormData(prev => { + if (!prev || !('hasCompostMaterial' in prev)) return prev; + + const newState = JSON.parse(JSON.stringify(prev)) as T & AddRawMaterialOperationModel; + newState.hasCompostMaterial = newState.hasCompostMaterial.filter(mat => mat['@id'] !== itemId); + + return newState; + }); + }; + + const handleCompostMaterialChange = (itemId: string) => + (e: React.ChangeEvent) => { + + const { name, value } = e.target; + const keys = name.split('.'); + + setFormData(prev => { + if (!prev || !('hasCompostMaterial' in prev)) return prev; + + const newState = JSON.parse(JSON.stringify(prev)) as T & AddRawMaterialOperationModel; + + const materialToUpdate = newState.hasCompostMaterial.find(mat => mat['@id'] === itemId); + if (!materialToUpdate) return prev; + + let currentLevel: any = materialToUpdate; + try { + for (let i = 0; i < keys.length - 1; i++) { + currentLevel = currentLevel[keys[i]]; + } + + const finalKey = keys[keys.length - 1]; + + const isNumeric = typeof currentLevel[finalKey] === 'number'; + currentLevel[finalKey] = isNumeric ? parseFloat(value) || 0 : value; + + } catch (error) { + console.error(`Error setting nested property "${name}" on item ${itemId}:`, error); + return prev; + } + + return newState; + }); + }; /** Field change handlers end */ /** -------------------------------------------------------------------------- */ @@ -216,24 +285,22 @@ const ActivityDynamicCRUDActions = ({ activity, onA onChange={handleChange} error={!(formData.madeBySensor as SensorShape).name?.trim()} /> )} - - {'hasResult' in formData && ( + {'hasResult' in formData && ( + - )} - {'hasResult' in formData && ( - )} - + + )} {'observedProperty' in formData && ( ({ activity, onA return ( <> - - {'hasAppliedAmount' in formData && ( + {'hasAppliedAmount' in formData && ( + ({ activity, onA '' : (formData.hasAppliedAmount as AppliedAmountShape)["numericValue"]} onChange={handleChange} error={isNaN((formData.hasAppliedAmount as AppliedAmountShape)['numericValue'])} /> - )} - {'hasAppliedAmount' in formData && ( - )} - + + )} ) } @@ -376,6 +441,60 @@ const ActivityDynamicCRUDActions = ({ activity, onA ) } + const renderCompostMaterials = () => { + return ( + <> + {'hasCompostMaterial' in formData && ( + + Compost materials + {(formData.hasCompostMaterial as AddRawMaterialOperationModel['hasCompostMaterial']).map((compMat) => { + return ( + + + + + + + + + + + + + + + + + ); + })} + + + + + )} + + ) + } + const renderNestedActivities = () => { // TODO: finish const nestedActivities = []; @@ -486,6 +605,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA {renderApplicationMethod()} {renderPesticide()} {renderFertilizer()} + {renderCompostMaterials()} {renderNestedActivities()} From f380c983e9e7534554f3a42a45bac5e8ec0f3128 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Thu, 30 Oct 2025 16:52:46 +0100 Subject: [PATCH 20/91] added is part of activity field to all interfaces --- src/models/FarmCalendarActivities.ts | 58 +++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/src/models/FarmCalendarActivities.ts b/src/models/FarmCalendarActivities.ts index 6d5494a..adf4416 100644 --- a/src/models/FarmCalendarActivities.ts +++ b/src/models/FarmCalendarActivities.ts @@ -16,6 +16,10 @@ export interface AddRawMaterialOperationModel extends BaseActivityModel { "@id": string; "@type": string; }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; responsibleAgent: string | null; usesAgriculturalMachinery: { "@id": string; @@ -46,6 +50,10 @@ export interface CompostOperationModel extends BaseActivityModel { "@id": string; "@type": string; }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; isOperatedOn: { "@id": string; "@type": string; @@ -67,6 +75,10 @@ export interface CompostTurningOperationModel extends BaseActivityModel { "@id": string; "@type": string; }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; responsibleAgent: string | null; usesAgriculturalMachinery: { "@id": string; @@ -86,6 +98,10 @@ export interface CropGrowthStageObservationModel extends BaseActivityModel { "@id": string; "@type": string; }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; hasAgriCrop: { "@id": string; "@type": string; @@ -107,6 +123,10 @@ export interface CropProtectionOperationModel extends BaseActivityModel { "@id": string; "@type": string; }[]; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; hasAppliedAmount: { "@id": string; "@type": string; @@ -135,6 +155,10 @@ export interface CropStressIndicatorObservationModel extends BaseActivityModel { "@id": string; "@type": string; }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; hasAgriCrop: { "@id": string; "@type": string; @@ -160,6 +184,10 @@ export interface DiseaseDetectionModel extends BaseActivityModel { "@id": string; "@type": string; }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; hasArea: string; hasResult: { "@id": string; @@ -178,6 +206,10 @@ export interface FertilizationOperationModel extends BaseActivityModel { "@id": string; "@type": string; }[]; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; hasAppliedAmount: { "@id": string; "@type": string; @@ -202,6 +234,10 @@ export interface GenericActivityModel extends BaseActivityModel { "@id": string; "@type": string; }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; responsibleAgent: string | null; usesAgriculturalMachinery: { "@id": string; @@ -222,7 +258,7 @@ export interface GenericAlertModel extends BaseActivityModel { relatedObservation: { "@id": string; "@type": string; - }; + } | null; } export interface GenericObservationModel extends BaseActivityModel { @@ -232,6 +268,10 @@ export interface GenericObservationModel extends BaseActivityModel { "@id": string; "@type": string; }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; madeBySensor: { "@id": string; "@type": string; @@ -254,6 +294,10 @@ export interface IrrigationOperationModel extends BaseActivityModel { "@id": string; "@type": string; }[]; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; hasAppliedAmount: { "@id": string; "@type": string; @@ -279,6 +323,10 @@ export interface SprayingRecommendationModel extends BaseActivityModel { "@id": string; "@type": string; }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; hasArea: string; hasResult: { "@id": string; @@ -305,6 +353,10 @@ export interface VigorEstimationModel extends BaseActivityModel { "@id": string; "@type": string; }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; hasArea: string; hasResult: { "@id": string; @@ -327,6 +379,10 @@ export interface YieldPredictionModel extends BaseActivityModel { "@id": string; "@type": string; }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; hasArea: string; hasResult: { "@id": string; From 9641e68d239e9807889d86d8c1cdca0e91ae82cf Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 31 Oct 2025 12:49:31 +0100 Subject: [PATCH 21/91] impl parent activity seelct and logic --- .../ActivityDynamicCRUDActions.tsx | 51 ++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index c3c295b..fbf4a81 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -18,6 +18,7 @@ import { PesticideModel } from "@models/Pesticide"; import { FertilizerModel } from "@models/Fertilizer"; import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; +import { FarmCalendarActivityModel } from "@models/FarmCalendarActivity"; const ActivityDynamicCRUDActions = ({ activity, onAdd, onDelete, onSave, loading }: ActivityDynamicCRUDActionsProps) => { @@ -28,6 +29,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA const [operatedOnCompostPile, setOperatedOnCompostPile] = useState(''); const [selectedPesticide, setSelectedPesticide] = useState(''); const [selectedFertilizer, setSelectedFertilizer] = useState(''); + const [parentActivity, setParentActivity] = useState(''); useEffect(() => { let parcelID: string | undefined; @@ -41,6 +43,19 @@ const ActivityDynamicCRUDActions = ({ activity, onA setSelectedParcel(idParts[idParts.length - 1]); } + let parentActivityID: string | undefined; + if ('isPartOfActivity' in formData) { + if (formData.isPartOfActivity) + parentActivityID = (formData as any).isPartOfActivity["@id"]; + } else if ('relatedObservation' in formData) { + if (formData.relatedObservation) + parentActivityID = (formData as any).relatedObservation["@id"]; + } + if (parentActivityID) { + const idParts = parentActivityID.split(':'); + setParentActivity(idParts[idParts.length - 1]); + } + let cropID: string | undefined; if ('hasAgriCrop' in formData) { cropID = (formData as any).hasAgriCrop["@id"]; @@ -265,12 +280,6 @@ const ActivityDynamicCRUDActions = ({ activity, onA ) } - const renderPartOfX = () => { - return ( - <> - ) - } - const renderSensorResultAndObservedProperty = () => { type SensorShape = { name: string }; type ResultShape = { unit: string; hasValue: string }; @@ -537,6 +546,19 @@ const ActivityDynamicCRUDActions = ({ activity, onA } else if ('operatedOn' in body) { (body.operatedOn as { '@id': string })['@id'] = `urn:farmcalendar:Parcel:${selectedParcel}`; } + if ('isPartOfActivity' in body) { + if (parentActivity) { + (body.isPartOfActivity as { '@id': string })['@id'] = `urn:farmcalendar:FarmCalendarActivity:${parentActivity}`; + } else { + body.isPartOfActivity = null; + } + } else if ('relatedObservation' in body) { + if (parentActivity) { + (body.relatedObservation as { '@id': string })['@id'] = `urn:farmcalendar:FarmCalendarActivity:${parentActivity}`; + } else { + body.relatedObservation = null; + } + } if ('hasAgriCrop' in body) { (body.hasAgriCrop as { '@id': string })['@id'] = `urn:farmcalendar:FarmCrop:${selectedAgriCrop}`; } @@ -596,7 +618,22 @@ const ActivityDynamicCRUDActions = ({ activity, onA getOptionLabel={item => `${item.identifier} (${item.category})`} getOptionValue={item => item["@id"].split(':')[3]} /> - {renderPartOfX()} + + endpoint='proxy/farmcalendar/api/v1/FarmCalendarActivities/?format=json' + label='Part of activity' + // Filtering out the current activity to avoid recursive links + transformResponse={resp => { + return resp.filter(fa => { + return fa["@id"].split(':')[3] !== formData["@id"].split(':')[3] + }) + }} + selectedValue={parentActivity} + setSelectedValue={setParentActivity} + getOptionLabel={ + item => `${item.title} (${dayjs(item.hasStartDatetime).format('YYYY-MM-DD HH:mm')} - ${dayjs(item.hasEndDatetime).format('YYYY-MM-DD HH:mm')})` + } + getOptionValue={item => item["@id"].split(':')[3]} + /> {renderSensorResultAndObservedProperty()} {renderSelectedCrop()} {renderHasArea()} From 3d262ab24ce6043fe22ed499cf73a763c7a38066 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 31 Oct 2025 15:37:14 +0100 Subject: [PATCH 22/91] comments adjusted and added --- .../ActivityDynamicCRUDActions.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index fbf4a81..998036d 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -323,7 +323,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA } const renderSelectedCrop = () => { - // TODO: filter if parcel is selected + // NTH: filter if parcel is selected return ( <> {'hasAgriCrop' in formData && ( @@ -415,7 +415,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA } const renderPesticide = () => { - // TODO: filter if parcel is selected + // NTH: filter if parcel is selected return ( <> {'usesPesticide' in formData && ( @@ -433,7 +433,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA } const renderFertilizer = () => { - // TODO: filter if parcel is selected + // NTH: filter if parcel is selected return ( <> {'usesFertilizer' in formData && ( @@ -618,6 +618,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA getOptionLabel={item => `${item.identifier} (${item.category})`} getOptionValue={item => item["@id"].split(':')[3]} /> + {/* NTH: string filtering of the displayed activities */} endpoint='proxy/farmcalendar/api/v1/FarmCalendarActivities/?format=json' label='Part of activity' From fbdcf3c3b2d65c60533a80a921712793f2e7da6d Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 31 Oct 2025 16:07:04 +0100 Subject: [PATCH 23/91] fix when setting parent activity after it has been null causing error --- .../ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index 998036d..be23ff0 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -547,6 +547,9 @@ const ActivityDynamicCRUDActions = ({ activity, onA (body.operatedOn as { '@id': string })['@id'] = `urn:farmcalendar:Parcel:${selectedParcel}`; } if ('isPartOfActivity' in body) { + if (!body.isPartOfActivity) { + body.isPartOfActivity = { '@id': '', '@type': 'FarmCalendarActivity' } + } if (parentActivity) { (body.isPartOfActivity as { '@id': string })['@id'] = `urn:farmcalendar:FarmCalendarActivity:${parentActivity}`; } else { From 68470ff8e3a978c1992ec779740ff7e7f3614a82 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 31 Oct 2025 16:22:44 +0100 Subject: [PATCH 24/91] impl generic select supporting data reception from parent component --- src/components/shared/GenericSelect/GenericSelect.tsx | 10 ++++++---- .../shared/GenericSelect/GenericSelect.types.ts | 1 + 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/shared/GenericSelect/GenericSelect.tsx b/src/components/shared/GenericSelect/GenericSelect.tsx index b96c80b..ab945a3 100644 --- a/src/components/shared/GenericSelect/GenericSelect.tsx +++ b/src/components/shared/GenericSelect/GenericSelect.tsx @@ -11,6 +11,7 @@ const GenericSelect = ({ selectedValue, setSelectedValue, transformResponse, + data = undefined, multiple = false, }: GenericSelectProps) => { const { fetchData, response, loading } = useFetch(endpoint, { method: 'GET' }); @@ -21,12 +22,13 @@ const GenericSelect = ({ }; useEffect(() => { - fetchData(); + if (!data) fetchData(); }, []); - const items = useMemo(() => - response && transformResponse ? transformResponse(response) : (response || []) as T[] - , [response, transformResponse]); + const items = useMemo(() => { + const dataToUse = data ? data : response; + return dataToUse && transformResponse ? transformResponse(dataToUse) : (dataToUse || []) as T[] + }, [data, response, transformResponse]); const valueToRender = useMemo(() => { if (loading) { diff --git a/src/components/shared/GenericSelect/GenericSelect.types.ts b/src/components/shared/GenericSelect/GenericSelect.types.ts index 1f82ae0..c4b8f80 100644 --- a/src/components/shared/GenericSelect/GenericSelect.types.ts +++ b/src/components/shared/GenericSelect/GenericSelect.types.ts @@ -1,5 +1,6 @@ interface GenericSelectPropsBase { endpoint: string; + data?: R; label: string; getOptionLabel: (item: T) => string; getOptionValue: (item: T) => string; From 841dd322c884e716ef540cc1b7014577d472ec95 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 31 Oct 2025 16:25:11 +0100 Subject: [PATCH 25/91] fetching and storing all calendar activities on the CRUD component, passing it to the generic select for the parent activity --- .../ActivityDynamicCRUDActions.tsx | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index be23ff0..25d96f6 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -19,6 +19,7 @@ import { FertilizerModel } from "@models/Fertilizer"; import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; import { FarmCalendarActivityModel } from "@models/FarmCalendarActivity"; +import useFetch from "@hooks/useFetch"; const ActivityDynamicCRUDActions = ({ activity, onAdd, onDelete, onSave, loading }: ActivityDynamicCRUDActionsProps) => { @@ -32,6 +33,8 @@ const ActivityDynamicCRUDActions = ({ activity, onA const [parentActivity, setParentActivity] = useState(''); useEffect(() => { + fetchDataAllActivities(); + let parcelID: string | undefined; if ('hasAgriParcel' in formData) { parcelID = (formData as any).hasAgriParcel["@id"]; @@ -99,7 +102,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA const idParts = fertilizerID.split(':'); setSelectedFertilizer(idParts[idParts.length - 1]); } - }, [formData]); + }, []); const { dialogProps, showDialog } = useDialog(); @@ -107,6 +110,24 @@ const ActivityDynamicCRUDActions = ({ activity, onA dialogProps.onClose(); }; + /** All calendar activities section start */ + const [allActivities, setAllActivities] = useState([]); + + const { fetchData: fetchDataAllActivities, response: responseAllActivities } = useFetch( + `proxy/farmcalendar/api/v1/FarmCalendarActivities/?format=json`, + { + method: 'GET', + } + ); + + useEffect(() => { + if (responseAllActivities) { + setAllActivities(responseAllActivities); + } + }, [responseAllActivities]) + /** All calendar activities section start */ + + /** Field change handlers start */ const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -623,7 +644,8 @@ const ActivityDynamicCRUDActions = ({ activity, onA /> {/* NTH: string filtering of the displayed activities */} - endpoint='proxy/farmcalendar/api/v1/FarmCalendarActivities/?format=json' + endpoint='' + data={allActivities} label='Part of activity' // Filtering out the current activity to avoid recursive links transformResponse={resp => { From 5bfcfae7b1a74cf9dfe19794eb20c00c79ace890 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 31 Oct 2025 16:27:48 +0100 Subject: [PATCH 26/91] comment fix --- .../ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index 25d96f6..5142e29 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -125,7 +125,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA setAllActivities(responseAllActivities); } }, [responseAllActivities]) - /** All calendar activities section start */ + /** All calendar activities section end */ /** Field change handlers start */ From 6c271f76ce6f3144eb7b1a8e1a15f182e770e6c3 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 31 Oct 2025 16:29:28 +0100 Subject: [PATCH 27/91] comment separator added --- .../ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index 5142e29..306ccf6 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -127,6 +127,7 @@ const ActivityDynamicCRUDActions = ({ activity, onA }, [responseAllActivities]) /** All calendar activities section end */ + /** -------------------------------------------------------------------------- */ /** Field change handlers start */ const handleChange = (e: React.ChangeEvent) => { From 99092277df42206b81c9f70c0e0f5065779fc6b1 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 31 Oct 2025 16:34:19 +0100 Subject: [PATCH 28/91] moved all calendar activities code section part --- .../ActivityDynamicCRUDActions.tsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index 306ccf6..b00a677 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -32,6 +32,23 @@ const ActivityDynamicCRUDActions = ({ activity, onA const [selectedFertilizer, setSelectedFertilizer] = useState(''); const [parentActivity, setParentActivity] = useState(''); + /** All calendar activities section start */ + const [allActivities, setAllActivities] = useState([]); + + const { fetchData: fetchDataAllActivities, response: responseAllActivities } = useFetch( + `proxy/farmcalendar/api/v1/FarmCalendarActivities/?format=json`, + { + method: 'GET', + } + ); + + useEffect(() => { + if (responseAllActivities) { + setAllActivities(responseAllActivities); + } + }, [responseAllActivities]) + /** All calendar activities section end */ + useEffect(() => { fetchDataAllActivities(); @@ -110,23 +127,6 @@ const ActivityDynamicCRUDActions = ({ activity, onA dialogProps.onClose(); }; - /** All calendar activities section start */ - const [allActivities, setAllActivities] = useState([]); - - const { fetchData: fetchDataAllActivities, response: responseAllActivities } = useFetch( - `proxy/farmcalendar/api/v1/FarmCalendarActivities/?format=json`, - { - method: 'GET', - } - ); - - useEffect(() => { - if (responseAllActivities) { - setAllActivities(responseAllActivities); - } - }, [responseAllActivities]) - /** All calendar activities section end */ - /** -------------------------------------------------------------------------- */ /** Field change handlers start */ From 27391fe97aa27b91c3a66635644883df4eaf0072 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 4 Nov 2025 11:41:23 +0100 Subject: [PATCH 29/91] finished impl nested activities, passing all activity types from calendar page and from activity crud actions to edit activity --- .../ActivityDynamicCRUDActions.tsx | 43 +++++++++++++++---- .../ActivityDynamicCRUDActions.types.ts | 2 + .../services/FarmCalendar/FarmCalendar.tsx | 2 +- .../FarmCalendarActivities/EditActivity.tsx | 4 ++ 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index b00a677..801b692 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -1,6 +1,6 @@ import { AddRawMaterialOperationModel, BaseActivityModel } from "@models/FarmCalendarActivities"; import { ActivityDynamicCRUDActionsProps } from "./ActivityDynamicCRUDActions.types"; -import { Box, Button, Card, CardContent, IconButton, List, ListItem, ListItemText, Stack, TextField, Typography } from "@mui/material"; +import { Box, Button, Card, CardContent, IconButton, List, ListItem, ListItemButton, ListItemText, Stack, TextField, Typography } from "@mui/material"; import dayjs, { Dayjs } from "dayjs"; import { useEffect, useState } from "react"; import { DateTimePicker } from "@mui/x-date-pickers"; @@ -20,8 +20,10 @@ import { FertilizerModel } from "@models/Fertilizer"; import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; import { FarmCalendarActivityModel } from "@models/FarmCalendarActivity"; import useFetch from "@hooks/useFetch"; +import { useNavigate } from "react-router-dom"; -const ActivityDynamicCRUDActions = ({ activity, onAdd, onDelete, onSave, loading }: ActivityDynamicCRUDActionsProps) => { +const ActivityDynamicCRUDActions = ({ activity, activityTypes, onAdd, onDelete, onSave, loading }: ActivityDynamicCRUDActionsProps) => { + const navigate = useNavigate(); const [formData, setFormData] = useState(activity); const [selectedParcel, setSelectedParcel] = useState(''); @@ -527,14 +529,34 @@ const ActivityDynamicCRUDActions = ({ activity, onA } const renderNestedActivities = () => { - // TODO: finish - const nestedActivities = []; + let nestedActivities = []; if ('hasMeasurement' in formData) { nestedActivities.push(...(formData.hasMeasurement as any)); } if ('hasNestedOperation' in formData) { nestedActivities.push(...(formData.hasNestedOperation as any)); } + if (nestedActivities.length && allActivities.length) { + const allActivitiesWithEndpoints = allActivities.map(a => { + return { + ...a, + activity_endpoint: activityTypes.find(at => at["@id"].split(':')[3] === a.activityType["@id"].split(':')[3])?.activity_endpoint + } + }) + + nestedActivities = nestedActivities.map(na => { + const naID = na["@id"].split(':')[3]; + const additionalParams = allActivitiesWithEndpoints.find(a => a["@id"].split(':')[3] === naID); + + return { + ...na, + title: additionalParams?.title, + hasStartDatetime: additionalParams?.hasStartDatetime, + hasEndDatetime: additionalParams?.hasEndDatetime, + activity_endpoint: additionalParams?.activity_endpoint + } + }) + } return ( <> {nestedActivities.length > 0 && @@ -543,10 +565,15 @@ const ActivityDynamicCRUDActions = ({ activity, onA Nested activities 10}> {nestedActivities.map((n: any) => { - return - + return + { + navigate(`/farm-calendar/edit-activity/${n["@id"].split(":")[3]}`, { state: { api: n.activity_endpoint, activityTypes: activityTypes } }) + + }}> + + })} diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.ts b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.ts index 68f0ae5..abd904a 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.ts +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.ts @@ -1,7 +1,9 @@ import { BaseActivityModel } from "@models/FarmCalendarActivities"; +import { FarmCalendarActivityTypeModel } from "@models/FarmCalendarActivityType"; export interface ActivityDynamicCRUDActionsProps { activity: T; + activityTypes: FarmCalendarActivityTypeModel[]; onSave?: (activityToSave: BaseActivityModel) => void; onAdd?: (activityToSave: BaseActivityModel) => void; onDelete?: () => void; diff --git a/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx b/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx index f9aa564..4df16b8 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx @@ -95,7 +95,7 @@ const FarmCalendarPage = () => { eventClick={ (info) => { const api = activityTypes.find(a => { return a["@id"].split(":")[3] === info.event.extendedProps.activityType["@id"].split(":")[3] })?.activity_endpoint - navigate(`edit-activity/${info.event.id.split(":")[3]}`, { state: { api: api } }) + navigate(`edit-activity/${info.event.id.split(":")[3]}`, { state: { api: api, activityTypes: activityTypes } }) } } onDateRangeChange={setDateRange} diff --git a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx index 9cd7740..7fc2fd0 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx @@ -24,6 +24,7 @@ import { YieldPredictionModel } from '@models/FarmCalendarActivities'; import ActivityDynamicCRUDActions from "@components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions"; +import { FarmCalendarActivityTypeModel } from "@models/FarmCalendarActivityType"; const ActivityFormComponentMap: { [key: string]: React.FC } = { 'AddRawMaterialOperation': (props) => {...props} />, @@ -45,6 +46,7 @@ const ActivityFormComponentMap: { [key: string]: React.FC } = { interface LocationState { api: string | undefined; + activityTypes: FarmCalendarActivityTypeModel[] | undefined; } const EditCalendarActivityPage = () => { @@ -57,6 +59,7 @@ const EditCalendarActivityPage = () => { const state = location.state as LocationState; const api = state?.api; + const activityTypes = state?.activityTypes; const { fetchData, loading, response, error } = useFetch( api ? `proxy/farmcalendar${api}${id}/?format=json` : '', @@ -127,6 +130,7 @@ const EditCalendarActivityPage = () => { return ( Date: Fri, 7 Nov 2025 13:56:03 +0100 Subject: [PATCH 30/91] added WIP fields for severity and irrigation systems --- .../ActivityDynamicCRUDActions.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index 801b692..d0165de 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -346,6 +346,26 @@ const ActivityDynamicCRUDActions = ({ activity, act ) } + const renderSeverity = () => { + return ( + <> + {'severity' in formData && ( +
severity WIP
+ )} + + ) + } + + const renderUsesIrrigationSystem = () => { + return ( + <> + {'usesIrrigationSystem' in formData && ( +
irrigation system WIP
+ )} + + ) + } + const renderSelectedCrop = () => { // NTH: filter if parcel is selected return ( @@ -688,11 +708,13 @@ const ActivityDynamicCRUDActions = ({ activity, act } getOptionValue={item => item["@id"].split(':')[3]} /> + {renderSeverity()} {renderSensorResultAndObservedProperty()} {renderSelectedCrop()} {renderHasArea()} {renderOperatedOnCompostPile()} {renderAppliedAmount()} + {renderUsesIrrigationSystem()} {renderApplicationMethod()} {renderPesticide()} {renderFertilizer()} From b6a82b14ed3aaced20748e119d7db05c8a78a9a1 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Mon, 10 Nov 2025 11:14:08 +0100 Subject: [PATCH 31/91] impl register activity component without API, activity model factory, passing activity types to register activity from farm calendar --- .../services/FarmCalendar/FarmCalendar.tsx | 2 +- .../RegisterActivity.tsx | 111 ++++- src/utils/activityModelFactory.ts | 381 ++++++++++++++++++ 3 files changed, 492 insertions(+), 2 deletions(-) create mode 100644 src/utils/activityModelFactory.ts diff --git a/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx b/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx index 4df16b8..832b7b2 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx @@ -88,7 +88,7 @@ const FarmCalendarPage = () => { <> - + } = { + 'AddRawMaterialOperation': (props) => {...props} />, + 'CompostOperation': (props) => {...props} />, + 'CompostTurningOperation': (props) => {...props} />, + 'CropGrowthStageObservation': (props) => {...props} />, + 'CropProtectionOperation': (props) => {...props} />, + 'CropStressIndicatorObservation': (props) => {...props} />, + 'DiseaseDetection': (props) => {...props} />, + 'FertilizationOperation': (props) => {...props} />, + 'FarmCalendarActivity': (props) => {...props} />, + 'Alert': (props) => {...props} />, + 'Observation': (props) => {...props} />, + 'IrrigationOperation': (props) => {...props} />, + 'SprayingRecommendation': (props) => {...props} />, + 'VigorEstimation': (props) => {...props} />, + 'YieldPrediction': (props) => {...props} />, +}; +interface LocationState { + activityTypes: FarmCalendarActivityTypeModel[] | undefined; +} + const RegisterCalendarActivityPage = () => { + const location = useLocation(); + + const state = location.state as LocationState; + const activityTypes = state?.activityTypes; + + const [selectedActivityType, setSelectedActivityType] = useState(''); + + const [activityData, setActivityData] = useState(null); + + useEffect(() => { + if (selectedActivityType) { + const getEmptyModel = activityModelFactory[selectedActivityType]; + + if (getEmptyModel) { + setActivityData(getEmptyModel()); + } else { + console.warn(`No empty model factory found for endpoint: ${selectedActivityType}`); + setActivityData(null); + } + } else { + setActivityData(null); + } + }, [selectedActivityType]); + + const handlePost = (activityToSave: BaseActivityModel) => { + console.log({ body: activityToSave }); + }; + + const renderForm = () => { + if (!activityData) return null; + + const activityType = activityData['@type']; + const FormComponent = ActivityFormComponentMap[activityType]; + + if (!FormComponent) { + return Unknown activity type: {activityType}; + } + + return ( + + ); + }; + return ( <> -
register activity works
+ + Register new activity + + + endpoint='' + data={activityTypes} + label='Activity type' + selectedValue={selectedActivityType} + setSelectedValue={setSelectedActivityType} + getOptionLabel={item => `${item.name}`} + getOptionValue={item => item.activity_endpoint} + /> + + {activityData && renderForm()} + ) } diff --git a/src/utils/activityModelFactory.ts b/src/utils/activityModelFactory.ts new file mode 100644 index 0000000..bd9624a --- /dev/null +++ b/src/utils/activityModelFactory.ts @@ -0,0 +1,381 @@ +import { + BaseActivityModel, + AddRawMaterialOperationModel, + CompostOperationModel, + CompostTurningOperationModel, + CropGrowthStageObservationModel, + CropProtectionOperationModel, + CropStressIndicatorObservationModel, + DiseaseDetectionModel, + FertilizationOperationModel, + GenericActivityModel, + GenericAlertModel, + GenericObservationModel, + IrrigationOperationModel, + SprayingRecommendationModel, + VigorEstimationModel, + YieldPredictionModel +} from '@models/FarmCalendarActivities'; + +const getEmptyBaseActivity = (): BaseActivityModel => ({ + "@id": "", + "@type": "", + activityType: { + "@id": "", + "@type": "" + }, + title: "", + details: "" +}); + + +const getEmptyAddRawMaterialOperation = (): AddRawMaterialOperationModel => ({ + ...getEmptyBaseActivity(), + "@type": "AddRawMaterialOperation", + hasStartDatetime: "", + hasEndDatetime: "", + operatedOn: { + "@id": "", + "@type": "" + }, + isPartOfActivity: null, + responsibleAgent: null, + usesAgriculturalMachinery: [], + hasCompostMaterial: [] +}); + +const getEmptyCompostOperation = (): CompostOperationModel => ({ + ...getEmptyBaseActivity(), + "@type": "CompostOperation", + hasStartDatetime: "", + hasEndDatetime: "", + responsibleAgent: null, + usesAgriculturalMachinery: [], + hasAgriParcel: { + "@id": "", + "@type": "" + }, + isPartOfActivity: null, + isOperatedOn: { + "@id": "", + "@type": "" + }, + hasNestedOperation: [], + hasMeasurement: [] +}); + +const getEmptyCompostTurningOperation = (): CompostTurningOperationModel => ({ + ...getEmptyBaseActivity(), + "@type": "CompostTurningOperation", + hasStartDatetime: "", + hasEndDatetime: "", + operatedOn: { + "@id": "", + "@type": "" + }, + isPartOfActivity: null, + responsibleAgent: null, + usesAgriculturalMachinery: [] +}); + +const getEmptyCropGrowthStageObservation = (): CropGrowthStageObservationModel => ({ + ...getEmptyBaseActivity(), + "@type": "CropGrowthStageObservation", + phenomenonTime: "", + hasEndDatetime: "", + madeBySensor: { + "@id": "", + "@type": "", + name: "" + }, + hasAgriParcel: { + "@id": "", + "@type": "" + }, + isPartOfActivity: null, + hasAgriCrop: { + "@id": "", + "@type": "" + }, + hasResult: { + "@id": "", + "@type": "", + unit: "", + hasValue: "" + }, + observedProperty: "" +}); + +const getEmptyCropProtectionOperation = (): CropProtectionOperationModel => ({ + ...getEmptyBaseActivity(), + "@type": "CropProtectionOperation", + hasStartDatetime: "", + hasEndDatetime: "", + responsibleAgent: null, + usesAgriculturalMachinery: [], + isPartOfActivity: null, + hasAppliedAmount: { + "@id": "", + "@type": "", + unit: "", + numericValue: 0 + }, + usesPesticide: { + "@id": "", + "@type": "" + }, + operatedOn: { + "@id": "", + "@type": "" + } +}); + +const getEmptyCropStressIndicatorObservation = (): CropStressIndicatorObservationModel => ({ + ...getEmptyBaseActivity(), + "@type": "CropStressIndicatorObservation", + phenomenonTime: "", + hasEndDatetime: "", + madeBySensor: { + "@id": "", + "@type": "", + name: "" + }, + hasAgriParcel: { + "@id": "", + "@type": "" + }, + isPartOfActivity: null, + hasAgriCrop: { + "@id": "", + "@type": "" + }, + hasResult: { + "@id": "", + "@type": "", + unit: "", + hasValue: "" + }, + observedProperty: "" +}); + +const getEmptyDiseaseDetection = (): DiseaseDetectionModel => ({ + ...getEmptyBaseActivity(), + "@type": "DiseaseDetection", + phenomenonTime: "", + hasEndDatetime: "", + madeBySensor: { + "@id": "", + "@type": "", + name: "" + }, + hasAgriParcel: { + "@id": "", + "@type": "" + }, + isPartOfActivity: null, + hasArea: "", + hasResult: { + "@id": "", + "@type": "", + unit: "", + hasValue: "" + }, + observedProperty: "" +}); + +const getEmptyFertilizationOperation = (): FertilizationOperationModel => ({ + ...getEmptyBaseActivity(), + "@type": "FertilizationOperation", + hasStartDatetime: "", + hasEndDatetime: "", + responsibleAgent: null, + usesAgriculturalMachinery: [], + isPartOfActivity: null, + hasAppliedAmount: { + "@id": "", + "@type": "", + unit: "", + numericValue: 0 + }, + hasApplicationMethod: "", + usesFertilizer: { + "@id": "", + "@type": "" + }, + operatedOn: { + "@id": "", + "@type": "" + } +}); + +const getEmptyGenericActivity = (): GenericActivityModel => ({ + ...getEmptyBaseActivity(), + "@type": "FarmCalendarActivity", + hasStartDatetime: "", + hasEndDatetime: "", + hasAgriParcel: { + "@id": "", + "@type": "" + }, + isPartOfActivity: null, + responsibleAgent: null, + usesAgriculturalMachinery: [] +}); + +const getEmptyGenericAlert = (): GenericAlertModel => ({ + ...getEmptyBaseActivity(), + "@type": "Alert", + severity: "", + hasAgriParcel: { + "@id": "", + "@type": "" + }, + validFrom: "", + validTo: "", + dateIssued: "", + quantityValue: {}, + relatedObservation: null +}); + +const getEmptyGenericObservation = (): GenericObservationModel => ({ + ...getEmptyBaseActivity(), + "@type": "Observation", + phenomenonTime: "", + hasEndDatetime: "", + hasAgriParcel: { + "@id": "", + "@type": "" + }, + isPartOfActivity: null, + madeBySensor: { + "@id": "", + "@type": "", + name: "" + }, + hasResult: { + "@id": "", + "@type": "", + unit: "", + hasValue: "" + }, + observedProperty: "" +}); + +const getEmptyIrrigationOperation = (): IrrigationOperationModel => ({ + ...getEmptyBaseActivity(), + "@type": "IrrigationOperation", + hasStartDatetime: "", + hasEndDatetime: "", + responsibleAgent: null, + usesAgriculturalMachinery: [], + isPartOfActivity: null, + hasAppliedAmount: { + "@id": "", + "@type": "", + unit: "", + numericValue: 0 + }, + usesIrrigationSystem: "", + operatedOn: { + "@id": "", + "@type": "" + } +}); + +const getEmptySprayingRecommendation = (): SprayingRecommendationModel => ({ + ...getEmptyBaseActivity(), + "@type": "SprayingRecommendation", + phenomenonTime: "", + hasEndDatetime: "", + madeBySensor: { + "@id": "", + "@type": "", + name: "" + }, + hasAgriParcel: { + "@id": "", + "@type": "" + }, + isPartOfActivity: null, + hasArea: "", + hasResult: { + "@id": "", + "@type": "", + unit: "", + hasValue: "" + }, + observedProperty: "", + usesPesticide: { + "@id": "", + "@type": "" + } +}); + +const getEmptyVigorEstimation = (): VigorEstimationModel => ({ + ...getEmptyBaseActivity(), + "@type": "VigorEstimation", + phenomenonTime: "", + hasEndDatetime: "", + madeBySensor: { + "@id": "", + "@type": "", + name: "" + }, + hasAgriParcel: { + "@id": "", + "@type": "" + }, + isPartOfActivity: null, + hasArea: "", + hasResult: { + "@id": "", + "@type": "", + unit: "", + hasValue: "" + }, + observedProperty: "" +}); + +const getEmptyYieldPrediction = (): YieldPredictionModel => ({ + ...getEmptyBaseActivity(), + "@type": "YieldPrediction", + phenomenonTime: "", + hasEndDatetime: "", + madeBySensor: { + "@id": "", + "@type": "", + name: "" + }, + hasAgriParcel: { + "@id": "", + "@type": "" + }, + isPartOfActivity: null, + hasArea: "", + hasResult: { + "@id": "", + "@type": "", + unit: "", + hasValue: "" + }, + observedProperty: "" +}); + + +export const activityModelFactory: Record BaseActivityModel> = { + "/api/v1/YieldPrediction/": getEmptyYieldPrediction, + "/api/v1/VigorEstimation/": getEmptyVigorEstimation, + "/api/v1/Observations/": getEmptyGenericObservation, + "/api/v1/SprayingRecommendation/": getEmptySprayingRecommendation, + "/api/v1/CropProtectionOperations/": getEmptyCropProtectionOperation, + "/api/v1/IrrigationOperations/": getEmptyIrrigationOperation, + "/api/v1/FertilizationOperations/": getEmptyFertilizationOperation, + "/api/v1/DiseaseDetection/": getEmptyDiseaseDetection, + "/api/v1/Alerts/": getEmptyGenericAlert, + "/api/v1/FarmCalendarActivities/": getEmptyGenericActivity, + "/api/v1/CropStressIndicatorObservations/": getEmptyCropStressIndicatorObservation, + "/api/v1/CropGrowthStageObservations/": getEmptyCropGrowthStageObservation, + "/api/v1/CompostTurningOperations/": getEmptyCompostTurningOperation, + "/api/v1/CompostOperations/": getEmptyCompostOperation, + "/api/v1/AddRawMaterialOperations/": getEmptyAddRawMaterialOperation, +}; \ No newline at end of file From 25960cfa871e99e19d014f591c4f3a79c1d49ff0 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Mon, 10 Nov 2025 12:17:00 +0100 Subject: [PATCH 32/91] fixed related observation field null case in activity crud actions --- .../ActivityDynamicCRUDActions.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index d0165de..05de7e1 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -625,8 +625,11 @@ const ActivityDynamicCRUDActions = ({ activity, act body.isPartOfActivity = null; } } else if ('relatedObservation' in body) { + if (!body.relatedObservation) { + body.relatedObservation = { '@id': '', '@type': 'Observation' } + } if (parentActivity) { - (body.relatedObservation as { '@id': string })['@id'] = `urn:farmcalendar:FarmCalendarActivity:${parentActivity}`; + (body.relatedObservation as { '@id': string })['@id'] = `urn:farmcalendar:Observation:${parentActivity}`; } else { body.relatedObservation = null; } From 94c00aa98dd65d1d39b215ebc2a377f59a5653ea Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Mon, 10 Nov 2025 12:23:14 +0100 Subject: [PATCH 33/91] tested adding activities and make adjustments to register activity and activity model factory - passing activity type id and added property type string values --- .../RegisterActivity.tsx | 47 ++++- src/utils/activityModelFactory.ts | 171 +++++++++--------- 2 files changed, 126 insertions(+), 92 deletions(-) diff --git a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/RegisterActivity.tsx b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/RegisterActivity.tsx index d294ce2..6ed131a 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/RegisterActivity.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/RegisterActivity.tsx @@ -22,7 +22,10 @@ import { Box, Typography } from "@mui/material"; import { activityModelFactory } from "@utils/activityModelFactory"; import { useEffect, useState } from "react"; import ActivityDynamicCRUDActions from "@components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions"; -import { useLocation } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; +import useFetch from "@hooks/useFetch"; +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import useSnackbar from "@hooks/useSnackbar"; const ActivityFormComponentMap: { [key: string]: React.FC } = { 'AddRawMaterialOperation': (props) => {...props} />, @@ -47,22 +50,34 @@ interface LocationState { const RegisterCalendarActivityPage = () => { const location = useLocation(); + const navigate = useNavigate(); + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); const state = location.state as LocationState; const activityTypes = state?.activityTypes; const [selectedActivityType, setSelectedActivityType] = useState(''); + const [api, setAPI] = useState(''); const [activityData, setActivityData] = useState(null); + const { fetchData, loading, response, error } = useFetch( + api ? `proxy/farmcalendar${api}` : '', + { method: 'POST' } + ); + useEffect(() => { if (selectedActivityType) { - const getEmptyModel = activityModelFactory[selectedActivityType]; + const selectedActivityTypeObject = activityTypes?.find(at => at["@id"] === selectedActivityType); + const api = selectedActivityTypeObject?.activity_endpoint ?? ''; + setAPI(api); + + const getEmptyModel = activityModelFactory[api]; - if (getEmptyModel) { - setActivityData(getEmptyModel()); + if (getEmptyModel && selectedActivityTypeObject) { + setActivityData(getEmptyModel(selectedActivityTypeObject["@id"].split(':')[3])); } else { - console.warn(`No empty model factory found for endpoint: ${selectedActivityType}`); + console.warn(`No empty model factory found for endpoint: ${api}`); setActivityData(null); } } else { @@ -72,8 +87,20 @@ const RegisterCalendarActivityPage = () => { const handlePost = (activityToSave: BaseActivityModel) => { console.log({ body: activityToSave }); + fetchData({ body: activityToSave }) }; + useEffect(() => { + if (error) showSnackbar('error', 'Error adding activity'); + }, [error]); + + useEffect(() => { + if (response) { + showSnackbar('success', 'Activity updated successfully.'); + navigate("/farm-calendar"); + } + }, [response]); + const renderForm = () => { if (!activityData) return null; @@ -89,7 +116,7 @@ const RegisterCalendarActivityPage = () => { activity={activityData} activityTypes={activityTypes} onAdd={handlePost} - // loading={loading} + loading={loading} /> ); }; @@ -106,11 +133,17 @@ const RegisterCalendarActivityPage = () => { selectedValue={selectedActivityType} setSelectedValue={setSelectedActivityType} getOptionLabel={item => `${item.name}`} - getOptionValue={item => item.activity_endpoint} + getOptionValue={item => item["@id"]} /> {activityData && renderForm()} + ) } diff --git a/src/utils/activityModelFactory.ts b/src/utils/activityModelFactory.ts index bd9624a..722255d 100644 --- a/src/utils/activityModelFactory.ts +++ b/src/utils/activityModelFactory.ts @@ -16,27 +16,28 @@ import { VigorEstimationModel, YieldPredictionModel } from '@models/FarmCalendarActivities'; +import dayjs from 'dayjs'; -const getEmptyBaseActivity = (): BaseActivityModel => ({ +const getEmptyBaseActivity = (id: string): BaseActivityModel => ({ "@id": "", "@type": "", activityType: { - "@id": "", - "@type": "" + "@id": `urn:farmcalendar:FarmCalendarActivityType:${id}`, + "@type": "FarmCalendarActivityType" }, title: "", details: "" }); -const getEmptyAddRawMaterialOperation = (): AddRawMaterialOperationModel => ({ - ...getEmptyBaseActivity(), +const getEmptyAddRawMaterialOperation = (id: string): AddRawMaterialOperationModel => ({ + ...getEmptyBaseActivity(id), "@type": "AddRawMaterialOperation", - hasStartDatetime: "", - hasEndDatetime: "", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), operatedOn: { "@id": "", - "@type": "" + "@type": "Parcel" }, isPartOfActivity: null, responsibleAgent: null, @@ -44,45 +45,45 @@ const getEmptyAddRawMaterialOperation = (): AddRawMaterialOperationModel => ({ hasCompostMaterial: [] }); -const getEmptyCompostOperation = (): CompostOperationModel => ({ - ...getEmptyBaseActivity(), +const getEmptyCompostOperation = (id: string): CompostOperationModel => ({ + ...getEmptyBaseActivity(id), "@type": "CompostOperation", - hasStartDatetime: "", - hasEndDatetime: "", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), responsibleAgent: null, usesAgriculturalMachinery: [], hasAgriParcel: { "@id": "", - "@type": "" + "@type": "Parcel" }, isPartOfActivity: null, isOperatedOn: { "@id": "", - "@type": "" + "@type": "CompostPile" }, hasNestedOperation: [], hasMeasurement: [] }); -const getEmptyCompostTurningOperation = (): CompostTurningOperationModel => ({ - ...getEmptyBaseActivity(), +const getEmptyCompostTurningOperation = (id: string): CompostTurningOperationModel => ({ + ...getEmptyBaseActivity(id), "@type": "CompostTurningOperation", - hasStartDatetime: "", - hasEndDatetime: "", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), operatedOn: { "@id": "", - "@type": "" + "@type": "Parcel" }, isPartOfActivity: null, responsibleAgent: null, usesAgriculturalMachinery: [] }); -const getEmptyCropGrowthStageObservation = (): CropGrowthStageObservationModel => ({ - ...getEmptyBaseActivity(), +const getEmptyCropGrowthStageObservation = (id: string): CropGrowthStageObservationModel => ({ + ...getEmptyBaseActivity(id), "@type": "CropGrowthStageObservation", - phenomenonTime: "", - hasEndDatetime: "", + phenomenonTime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), madeBySensor: { "@id": "", "@type": "", @@ -90,12 +91,12 @@ const getEmptyCropGrowthStageObservation = (): CropGrowthStageObservationModel = }, hasAgriParcel: { "@id": "", - "@type": "" + "@type": "Parcel" }, isPartOfActivity: null, hasAgriCrop: { "@id": "", - "@type": "" + "@type": "FarmCrop" }, hasResult: { "@id": "", @@ -106,11 +107,11 @@ const getEmptyCropGrowthStageObservation = (): CropGrowthStageObservationModel = observedProperty: "" }); -const getEmptyCropProtectionOperation = (): CropProtectionOperationModel => ({ - ...getEmptyBaseActivity(), +const getEmptyCropProtectionOperation = (id: string): CropProtectionOperationModel => ({ + ...getEmptyBaseActivity(id), "@type": "CropProtectionOperation", - hasStartDatetime: "", - hasEndDatetime: "", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), responsibleAgent: null, usesAgriculturalMachinery: [], isPartOfActivity: null, @@ -122,19 +123,19 @@ const getEmptyCropProtectionOperation = (): CropProtectionOperationModel => ({ }, usesPesticide: { "@id": "", - "@type": "" + "@type": "Pesticide" }, operatedOn: { "@id": "", - "@type": "" + "@type": "Parcel" } }); -const getEmptyCropStressIndicatorObservation = (): CropStressIndicatorObservationModel => ({ - ...getEmptyBaseActivity(), +const getEmptyCropStressIndicatorObservation = (id: string): CropStressIndicatorObservationModel => ({ + ...getEmptyBaseActivity(id), "@type": "CropStressIndicatorObservation", - phenomenonTime: "", - hasEndDatetime: "", + phenomenonTime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), madeBySensor: { "@id": "", "@type": "", @@ -142,12 +143,12 @@ const getEmptyCropStressIndicatorObservation = (): CropStressIndicatorObservatio }, hasAgriParcel: { "@id": "", - "@type": "" + "@type": "Parcel" }, isPartOfActivity: null, hasAgriCrop: { "@id": "", - "@type": "" + "@type": "FarmCrop" }, hasResult: { "@id": "", @@ -158,11 +159,11 @@ const getEmptyCropStressIndicatorObservation = (): CropStressIndicatorObservatio observedProperty: "" }); -const getEmptyDiseaseDetection = (): DiseaseDetectionModel => ({ - ...getEmptyBaseActivity(), +const getEmptyDiseaseDetection = (id: string): DiseaseDetectionModel => ({ + ...getEmptyBaseActivity(id), "@type": "DiseaseDetection", - phenomenonTime: "", - hasEndDatetime: "", + phenomenonTime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), madeBySensor: { "@id": "", "@type": "", @@ -170,7 +171,7 @@ const getEmptyDiseaseDetection = (): DiseaseDetectionModel => ({ }, hasAgriParcel: { "@id": "", - "@type": "" + "@type": "Parcel" }, isPartOfActivity: null, hasArea: "", @@ -183,11 +184,11 @@ const getEmptyDiseaseDetection = (): DiseaseDetectionModel => ({ observedProperty: "" }); -const getEmptyFertilizationOperation = (): FertilizationOperationModel => ({ - ...getEmptyBaseActivity(), +const getEmptyFertilizationOperation = (id: string): FertilizationOperationModel => ({ + ...getEmptyBaseActivity(id), "@type": "FertilizationOperation", - hasStartDatetime: "", - hasEndDatetime: "", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), responsibleAgent: null, usesAgriculturalMachinery: [], isPartOfActivity: null, @@ -200,51 +201,51 @@ const getEmptyFertilizationOperation = (): FertilizationOperationModel => ({ hasApplicationMethod: "", usesFertilizer: { "@id": "", - "@type": "" + "@type": "Fertilizer" }, operatedOn: { "@id": "", - "@type": "" + "@type": "Parcel" } }); -const getEmptyGenericActivity = (): GenericActivityModel => ({ - ...getEmptyBaseActivity(), +const getEmptyGenericActivity = (id: string): GenericActivityModel => ({ + ...getEmptyBaseActivity(id), "@type": "FarmCalendarActivity", - hasStartDatetime: "", - hasEndDatetime: "", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), hasAgriParcel: { "@id": "", - "@type": "" + "@type": "Parcel" }, isPartOfActivity: null, responsibleAgent: null, usesAgriculturalMachinery: [] }); -const getEmptyGenericAlert = (): GenericAlertModel => ({ - ...getEmptyBaseActivity(), +const getEmptyGenericAlert = (id: string): GenericAlertModel => ({ + ...getEmptyBaseActivity(id), "@type": "Alert", severity: "", hasAgriParcel: { "@id": "", - "@type": "" + "@type": "Parcel" }, - validFrom: "", - validTo: "", + validFrom: dayjs().toISOString(), + validTo: dayjs().toISOString(), dateIssued: "", quantityValue: {}, relatedObservation: null }); -const getEmptyGenericObservation = (): GenericObservationModel => ({ - ...getEmptyBaseActivity(), +const getEmptyGenericObservation = (id: string): GenericObservationModel => ({ + ...getEmptyBaseActivity(id), "@type": "Observation", - phenomenonTime: "", - hasEndDatetime: "", + phenomenonTime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), hasAgriParcel: { "@id": "", - "@type": "" + "@type": "Parcel" }, isPartOfActivity: null, madeBySensor: { @@ -261,11 +262,11 @@ const getEmptyGenericObservation = (): GenericObservationModel => ({ observedProperty: "" }); -const getEmptyIrrigationOperation = (): IrrigationOperationModel => ({ - ...getEmptyBaseActivity(), +const getEmptyIrrigationOperation = (id: string): IrrigationOperationModel => ({ + ...getEmptyBaseActivity(id), "@type": "IrrigationOperation", - hasStartDatetime: "", - hasEndDatetime: "", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), responsibleAgent: null, usesAgriculturalMachinery: [], isPartOfActivity: null, @@ -278,15 +279,15 @@ const getEmptyIrrigationOperation = (): IrrigationOperationModel => ({ usesIrrigationSystem: "", operatedOn: { "@id": "", - "@type": "" + "@type": "Parcel" } }); -const getEmptySprayingRecommendation = (): SprayingRecommendationModel => ({ - ...getEmptyBaseActivity(), +const getEmptySprayingRecommendation = (id: string): SprayingRecommendationModel => ({ + ...getEmptyBaseActivity(id), "@type": "SprayingRecommendation", - phenomenonTime: "", - hasEndDatetime: "", + phenomenonTime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), madeBySensor: { "@id": "", "@type": "", @@ -294,7 +295,7 @@ const getEmptySprayingRecommendation = (): SprayingRecommendationModel => ({ }, hasAgriParcel: { "@id": "", - "@type": "" + "@type": "Parcel" }, isPartOfActivity: null, hasArea: "", @@ -307,15 +308,15 @@ const getEmptySprayingRecommendation = (): SprayingRecommendationModel => ({ observedProperty: "", usesPesticide: { "@id": "", - "@type": "" + "@type": "Pesticide" } }); -const getEmptyVigorEstimation = (): VigorEstimationModel => ({ - ...getEmptyBaseActivity(), +const getEmptyVigorEstimation = (id: string): VigorEstimationModel => ({ + ...getEmptyBaseActivity(id), "@type": "VigorEstimation", - phenomenonTime: "", - hasEndDatetime: "", + phenomenonTime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), madeBySensor: { "@id": "", "@type": "", @@ -323,7 +324,7 @@ const getEmptyVigorEstimation = (): VigorEstimationModel => ({ }, hasAgriParcel: { "@id": "", - "@type": "" + "@type": "Parcel" }, isPartOfActivity: null, hasArea: "", @@ -336,11 +337,11 @@ const getEmptyVigorEstimation = (): VigorEstimationModel => ({ observedProperty: "" }); -const getEmptyYieldPrediction = (): YieldPredictionModel => ({ - ...getEmptyBaseActivity(), +const getEmptyYieldPrediction = (id: string): YieldPredictionModel => ({ + ...getEmptyBaseActivity(id), "@type": "YieldPrediction", - phenomenonTime: "", - hasEndDatetime: "", + phenomenonTime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), madeBySensor: { "@id": "", "@type": "", @@ -348,7 +349,7 @@ const getEmptyYieldPrediction = (): YieldPredictionModel => ({ }, hasAgriParcel: { "@id": "", - "@type": "" + "@type": "Parcel" }, isPartOfActivity: null, hasArea: "", @@ -362,7 +363,7 @@ const getEmptyYieldPrediction = (): YieldPredictionModel => ({ }); -export const activityModelFactory: Record BaseActivityModel> = { +export const activityModelFactory: Record BaseActivityModel> = { "/api/v1/YieldPrediction/": getEmptyYieldPrediction, "/api/v1/VigorEstimation/": getEmptyVigorEstimation, "/api/v1/Observations/": getEmptyGenericObservation, From 27496419ca438c70e8a8619077319a2b637bc899 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Thu, 20 Nov 2025 12:40:40 +0100 Subject: [PATCH 34/91] impl use fetch and generic select support for OPTIONS calls --- src/components/shared/GenericSelect/GenericSelect.tsx | 3 ++- src/components/shared/GenericSelect/GenericSelect.types.ts | 1 + src/hooks/useFetch.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/shared/GenericSelect/GenericSelect.tsx b/src/components/shared/GenericSelect/GenericSelect.tsx index ab945a3..551f962 100644 --- a/src/components/shared/GenericSelect/GenericSelect.tsx +++ b/src/components/shared/GenericSelect/GenericSelect.tsx @@ -6,6 +6,7 @@ import useFetch from '@hooks/useFetch'; const GenericSelect = ({ endpoint, label, + method = "GET", getOptionLabel, getOptionValue, selectedValue, @@ -14,7 +15,7 @@ const GenericSelect = ({ data = undefined, multiple = false, }: GenericSelectProps) => { - const { fetchData, response, loading } = useFetch(endpoint, { method: 'GET' }); + const { fetchData, response, loading } = useFetch(endpoint, { method: method }); const handleChange = (event: SelectChangeEvent) => { const { target: { value } } = event; diff --git a/src/components/shared/GenericSelect/GenericSelect.types.ts b/src/components/shared/GenericSelect/GenericSelect.types.ts index c4b8f80..ee2cb35 100644 --- a/src/components/shared/GenericSelect/GenericSelect.types.ts +++ b/src/components/shared/GenericSelect/GenericSelect.types.ts @@ -2,6 +2,7 @@ interface GenericSelectPropsBase { endpoint: string; data?: R; label: string; + method?: "GET" | "OPTIONS"; getOptionLabel: (item: T) => string; getOptionValue: (item: T) => string; transformResponse?: (response: R) => T[]; diff --git a/src/hooks/useFetch.ts b/src/hooks/useFetch.ts index d007aca..46cdff1 100644 --- a/src/hooks/useFetch.ts +++ b/src/hooks/useFetch.ts @@ -2,7 +2,7 @@ import { useSession } from "@contexts/SessionContext"; import { useState } from "react"; interface FetchOptions { - method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS"; headers?: Record; body?: any; responseType?: 'json' | 'blob'; From 048bc020384ffd938c9b5b95bbf4e15242e49d33 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Thu, 20 Nov 2025 12:48:18 +0100 Subject: [PATCH 35/91] impl severity and uses irrigation fields --- .../ActivityDynamicCRUDActions.tsx | 52 +++++++++++++++++-- src/models/FarmCalendarActivities.ts | 26 ++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index 05de7e1..3b8cbc0 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -1,4 +1,4 @@ -import { AddRawMaterialOperationModel, BaseActivityModel } from "@models/FarmCalendarActivities"; +import { AddRawMaterialOperationModel, BaseActivityModel, GenericAlertOptions, IrrigationOperationOptions } from "@models/FarmCalendarActivities"; import { ActivityDynamicCRUDActionsProps } from "./ActivityDynamicCRUDActions.types"; import { Box, Button, Card, CardContent, IconButton, List, ListItem, ListItemButton, ListItemText, Stack, TextField, Typography } from "@mui/material"; import dayjs, { Dayjs } from "dayjs"; @@ -33,6 +33,8 @@ const ActivityDynamicCRUDActions = ({ activity, act const [selectedPesticide, setSelectedPesticide] = useState(''); const [selectedFertilizer, setSelectedFertilizer] = useState(''); const [parentActivity, setParentActivity] = useState(''); + const [severity, setSeverity] = useState(''); + const [usesIrrigationSystem, setUsesIrrigationSystem] = useState(''); /** All calendar activities section start */ const [allActivities, setAllActivities] = useState([]); @@ -121,6 +123,22 @@ const ActivityDynamicCRUDActions = ({ activity, act const idParts = fertilizerID.split(':'); setSelectedFertilizer(idParts[idParts.length - 1]); } + + let severity: string | undefined; + if ('severity' in formData) { + severity = (formData as any).severity; + } + if (severity) { + setSeverity(severity); + } + + let usesIrrigationSystem: string | undefined; + if ('usesIrrigationSystem' in formData) { + usesIrrigationSystem = (formData as any).usesIrrigationSystem; + } + if (usesIrrigationSystem) { + setUsesIrrigationSystem(usesIrrigationSystem); + } }, []); const { dialogProps, showDialog } = useDialog(); @@ -350,7 +368,16 @@ const ActivityDynamicCRUDActions = ({ activity, act return ( <> {'severity' in formData && ( -
severity WIP
+ + endpoint='proxy/farmcalendar/api/v1/Alerts/?format=json' + method="OPTIONS" + label='Severity' + selectedValue={severity} + setSelectedValue={setSeverity} + transformResponse={a => a.actions.POST.severity.choices} + getOptionLabel={item => item.display_name} + getOptionValue={item => item.value} + /> )} ) @@ -360,7 +387,16 @@ const ActivityDynamicCRUDActions = ({ activity, act return ( <> {'usesIrrigationSystem' in formData && ( -
irrigation system WIP
+ + endpoint='proxy/farmcalendar/api/v1/IrrigationOperations/?format=json' + method="OPTIONS" + label='Irrigation system' + selectedValue={usesIrrigationSystem} + setSelectedValue={setUsesIrrigationSystem} + transformResponse={a => a.actions.POST.usesIrrigationSystem.choices} + getOptionLabel={item => item.display_name} + getOptionValue={item => item.value} + /> )} ) @@ -649,6 +685,16 @@ const ActivityDynamicCRUDActions = ({ activity, act if ('usesFertilizer' in body) { (body.usesFertilizer as { '@id': string })['@id'] = `urn:farmcalendar:Fertilizer:${selectedFertilizer}`; } + if ('severity' in body) { + body.severity = severity; + } + // Necessary for generic alerts as they have this field set to null + if ('quantityValue' in body && !body.quantityValue) { + body.quantityValue = {}; + } + if ('usesIrrigationSystem' in body) { + body.usesIrrigationSystem = usesIrrigationSystem; + } return body; } diff --git a/src/models/FarmCalendarActivities.ts b/src/models/FarmCalendarActivities.ts index adf4416..7c296f0 100644 --- a/src/models/FarmCalendarActivities.ts +++ b/src/models/FarmCalendarActivities.ts @@ -261,6 +261,19 @@ export interface GenericAlertModel extends BaseActivityModel { } | null; } +export interface GenericAlertOptions { + actions: { + POST: { + severity: { + choices: { + value: string, + display_name: string + }[] + } + } + } +} + export interface GenericObservationModel extends BaseActivityModel { phenomenonTime: string; hasEndDatetime: string; @@ -311,6 +324,19 @@ export interface IrrigationOperationModel extends BaseActivityModel { }; } +export interface IrrigationOperationOptions { + actions: { + POST: { + usesIrrigationSystem: { + choices: { + value: string, + display_name: string + }[] + } + } + } +} + export interface SprayingRecommendationModel extends BaseActivityModel { phenomenonTime: string; hasEndDatetime: string; From 87102db9ad2184c545f679bd88980359494aa54e Mon Sep 17 00:00:00 2001 From: Felipe Arruda Pontes Date: Thu, 27 Nov 2025 08:59:39 +0100 Subject: [PATCH 36/91] building ci for ARM --- .env.sample | 5 ++ .github/workflows/docker-image.yml | 121 +++++++++++++++++++++++++---- Dockerfile | 3 + docker-compose.yml | 7 +- 4 files changed, 119 insertions(+), 17 deletions(-) diff --git a/.env.sample b/.env.sample index 0cd7a16..df6d8fd 100644 --- a/.env.sample +++ b/.env.sample @@ -1,2 +1,7 @@ +#build +TAG=latest +DOCKER_REGISTRY=openagri-eu +SOURCE_REPO=https://github.com/openagri-eu/openagri-pestanddiseasemanagement +## DB VITE_API_URL=https://gk.test.horizon-openagri.eu/api/ SERVICE_PORT=8007 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 8ee9e76..2b30631 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,29 +1,25 @@ -name: Docker Image CI +name: Docker Multi-Platform Builds on: push: branches: [ main ] workflow_dispatch: - inputs: - branch: - description: 'Branch to run the workflow on' - required: false release: - types: - - created + types: [ created ] jobs: - build-and-push: + + # ------------------------- + # AMD64 Build + # ------------------------- + build-amd64: runs-on: ubuntu-latest permissions: contents: read packages: write - steps: - name: Checkout code uses: actions/checkout@v4 - with: - fetch-depth: 0 # Saves time by not fetching other branches - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -35,16 +31,111 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Set lowercase repository name + - name: Normalize repo and sanitize ref run: | echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + SAFE_REF=$(echo "${GITHUB_REF_NAME}" | tr / -) + echo "SAFE_REF=$SAFE_REF" >> $GITHUB_ENV - - name: Build and push Docker image - id: docker_build + - name: Build and push AMD64 image uses: docker/build-push-action@v6 with: context: . + file: Dockerfile + build-args: SOURCE_REPO=https://github.com/${{ github.repository }} + platforms: linux/amd64 push: true - tags: ghcr.io/${{ env.REPO_LOWER }}:latest + tags: | + ghcr.io/${{ env.REPO_LOWER }}:${{ env.SAFE_REF }}-amd64 + ${{ github.ref_name == 'main' && format('ghcr.io/{0}:latest-amd64', env.REPO_LOWER) || '' }} cache-from: type=gha cache-to: type=gha,mode=max + + # ------------------------- + # ARM64 Build + # ------------------------- + build-arm64: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker daemon (containerd features) + uses: docker/setup-docker-action@v4 + with: + daemon-config: | + { + "debug": true, + "features": { "containerd-snapshotter": true } + } + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Normalize repo and sanitize ref + run: | + echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + SAFE_REF=$(echo "${GITHUB_REF_NAME}" | tr / -) + echo "SAFE_REF=$SAFE_REF" >> $GITHUB_ENV + + - name: Build and push ARM64 image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + build-args: BUILDKIT_PROGRESS=plain,SOURCE_REPO=https://github.com/${{ github.repository }} + platforms: linux/arm64 + push: true + tags: | + ghcr.io/${{ env.REPO_LOWER }}:${{ env.SAFE_REF }}-arm64 + ${{ github.ref_name == 'main' && format('ghcr.io/{0}:latest-arm64', env.REPO_LOWER) || '' }} + cache-from: type=gha,scope=arm64 + cache-to: type=gha,scope=arm64,mode=max + + # ------------------------- + # Create multi-arch manifest + # ------------------------- + manifest: + needs: [ build-amd64, build-arm64 ] + runs-on: ubuntu-latest + steps: + - name: Set up Docker Buildx (for imagetools) + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Normalize repo and sanitize ref + run: | + echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + SAFE_REF=$(echo "${GITHUB_REF_NAME}" | tr / -) + echo "SAFE_REF=$SAFE_REF" >> $GITHUB_ENV + + - name: Create multi-arch manifest for ref tag + run: | + docker buildx imagetools create \ + --tag ghcr.io/${{ env.REPO_LOWER }}:${{ env.SAFE_REF }} \ + ghcr.io/${{ env.REPO_LOWER }}:${{ env.SAFE_REF }}-amd64 \ + ghcr.io/${{ env.REPO_LOWER }}:${{ env.SAFE_REF }}-arm64 + + - name: Also tag :latest on main + if: ${{ github.ref_name == 'main' }} + run: | + docker buildx imagetools create \ + --tag ghcr.io/${{ env.REPO_LOWER }}:latest \ + ghcr.io/${{ env.REPO_LOWER }}:latest-amd64 \ + ghcr.io/${{ env.REPO_LOWER }}:latest-arm64 diff --git a/Dockerfile b/Dockerfile index 6761b26..fc17463 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,7 @@ +ARG SOURCE_REPO=https://github.com/openagri-eu/openagri-userdashboard + FROM node:22.17.1 AS builder +LABEL org.opencontainers.image.source=${SOURCE_REPO} # set working directory WORKDIR /app diff --git a/docker-compose.yml b/docker-compose.yml index c39ddab..5752b17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,10 @@ services: web: - build: . - image: ghcr.io/openagri-eu/openagri-userdashboard:latest + build: + context: . + args: + SOURCE_REPO: ${SOURCE_REPO} + image: ghcr.io/${DOCKER_REGISTRY}/openagri-userdashboard:${TAG} ports: - "${SERVICE_PORT}:${SERVICE_PORT}" environment: From ca1979fa5e4510181a3e6abfbaa1e86a773f4e66 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Thu, 18 Dec 2025 12:00:48 +0100 Subject: [PATCH 37/91] started impl hicharts for soil moisture analysis --- package-lock.json | 25 +++ package.json | 2 + src/models/SoilMoisture.ts | 35 +-- .../Irrigation/SoilMoistureAnalysis.tsx | 201 ++++++++++-------- 4 files changed, 162 insertions(+), 101 deletions(-) diff --git a/package-lock.json b/package-lock.json index e8d6677..4e27138 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,14 @@ "@fullcalendar/interaction": "^6.1.19", "@fullcalendar/react": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19", + "@highcharts/react": "^4.1.0", "@mui/icons-material": "^7.2.0", "@mui/material": "^7.2.0", "@mui/x-charts": "^8.11.1", "@mui/x-date-pickers": "^8.10.2", "@toolpad/core": "^0.16.0", "dayjs": "^1.11.13", + "highcharts": "^12.4.0", "jwt-decode": "^4.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -2434,6 +2436,23 @@ "@fullcalendar/core": "~6.1.19" } }, + "node_modules/@highcharts/react": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@highcharts/react/-/react-4.1.0.tgz", + "integrity": "sha512-sgGR8rAmx3tet8fP9bWUhVvt5nOuw6A0Ybby+q463g/9zQOmoA0wJ3TwLmKJm881UwN7++kkiMwoH4AeuMKsaQ==", + "license": "See license in LICENSE", + "peerDependencies": { + "@types/react": ">=18", + "highcharts": ">=12.0.0", + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -6211,6 +6230,12 @@ "node": ">= 0.4" } }, + "node_modules/highcharts": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/highcharts/-/highcharts-12.4.0.tgz", + "integrity": "sha512-o6UxxfChSUrvrZUbWrAuqL1HO/+exhAUPcZY6nnqLsadZQlnP16d082sg7DnXKZCk1gtfkyfkp6g3qkIZ9miZg==", + "license": "https://www.highcharts.com/license" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", diff --git a/package.json b/package.json index 52f281d..8e16b00 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,14 @@ "@fullcalendar/interaction": "^6.1.19", "@fullcalendar/react": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19", + "@highcharts/react": "^4.1.0", "@mui/icons-material": "^7.2.0", "@mui/material": "^7.2.0", "@mui/x-charts": "^8.11.1", "@mui/x-date-pickers": "^8.10.2", "@toolpad/core": "^0.16.0", "dayjs": "^1.11.13", + "highcharts": "^12.4.0", "jwt-decode": "^4.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/src/models/SoilMoisture.ts b/src/models/SoilMoisture.ts index 98049ea..fd6c05f 100644 --- a/src/models/SoilMoisture.ts +++ b/src/models/SoilMoisture.ts @@ -14,6 +14,11 @@ export interface DatasetRow { [key: string]: any; // Needed for chart }; +export interface DatasetResponse { + data_points: DatasetRow[]; + high_dose_irrigation_days: string[] +} + export interface SoilMoistureResponseJSONLD { '@context': string[]; '@graph': SoilMoistureGraphEntry[]; @@ -72,19 +77,19 @@ interface SoilMoistureGraphEntry { }; } -export interface SoilMoistureResponseJSON { - - "dataset_id": string, - "time_period": string[], - "irrigation_events_detected": number, - "precipitation_events": number, - "high_dose_irrigation_events": number, - "high_dose_irrigation_events_dates": string[], - "field_capacity": number, - "stress_level": number, - "number_of_saturation_days": number, - "saturation_dates": string[], - "no_of_stress_days": number, - "stress_dates": string[] - +export interface SoilMoistureResponseJSON { + + "dataset_id": string, + "time_period": string[], + "irrigation_events_detected": number, + "precipitation_events": number, + "high_dose_irrigation_events": number, + "high_dose_irrigation_events_dates": string[], + "field_capacity": number, + "stress_level": number, + "number_of_saturation_days": number, + "saturation_dates": string[], + "no_of_stress_days": number, + "stress_dates": string[] + } \ No newline at end of file diff --git a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx index 2c5a29c..5c6370d 100644 --- a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx +++ b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx @@ -1,50 +1,88 @@ +import { useMemo, useState, useEffect } from 'react'; +import * as Highcharts from 'highcharts'; +import { Chart, XAxis, YAxis, Tooltip, Legend } from '@highcharts/react'; +import { Line } from '@highcharts/react/series'; + import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; import useFetch from "@hooks/useFetch"; -import { DatasetRow, SoilMoistureResponseJSON } from "@models/SoilMoisture"; -import { Box, Card, CardContent, Skeleton, Typography, /* useTheme */ } from "@mui/material"; -// import { LineChart } from "@mui/x-charts"; -// import dayjs from "dayjs"; -import { useEffect, useState } from "react"; - -// const dateFormatter = Intl.DateTimeFormat(undefined, { -// month: '2-digit', -// day: '2-digit', -// }); +import { DatasetResponse, DatasetRow } from "@models/SoilMoisture"; +import { Box, Card, CardContent, Skeleton, Typography } from "@mui/material"; +import dayjs from "dayjs"; const SoilMoistureAnalysisPage = () => { - // const theme = useTheme(); - const [selectedDataset, setSelectedDataset] = useState(''); - // const [formattedData, setFormattedData] = useState([]); - - const { fetchData: analysisFetchData, loading: analysisLoading, response: analysisResponse } = useFetch( - `proxy/irrigation/api/v1/dataset/${selectedDataset}/analysis/?formatting=JSON`, - { - method: 'GET' - } - ) - - const { fetchData: datasetFetchData, loading: datasetLoading, response: datasetResponse } = useFetch( - `proxy/irrigation/api/v1/dataset/${selectedDataset}/?formatting=JSON`, + const { fetchData: datapointsFetchData, loading: datapointsLoading, response: datapointsResponse } = useFetch( + `proxy/irrigation/api/v1/dataset/${selectedDataset}/irrigation-datapoints/?formatting=JSON`, { method: 'GET' } ) useEffect(() => { - if (selectedDataset && !analysisLoading) { - analysisFetchData(); - datasetFetchData(); + if (selectedDataset) { + datapointsFetchData(); } }, [selectedDataset]) - // useEffect(() => { - // if (datasetResponse) { - // const formattedData = datasetResponse?.slice(0,200); - // setFormattedData(formattedData); - // } - // }, [datasetResponse]) + const { seriesData, plotLinesData } = useMemo(() => { + if (!datapointsResponse || datapointsResponse.data_points.length === 0) { + return { seriesData: [], plotLinesData: [] }; + } + + const { data_points, high_dose_irrigation_days } = datapointsResponse; + + const soilMoistureKeys = ['soil_moisture_10', 'soil_moisture_20', 'soil_moisture_30', 'soil_moisture_40', 'soil_moisture_50', 'soil_moisture_60']; + + const preparedSeriesData = soilMoistureKeys.map(key => { + const depth = key.split('_')[2]; + + const data = data_points + .map(d => { + const timestamp = dayjs(d.date).valueOf(); + const value = d[key as keyof DatasetRow] as number; + return [timestamp, value] as [number, number]; + }) + .filter(([_ts, val]) => val !== 0) as [number, number][]; + + return { + name: `Soil Moisture ${depth}cm`, + data: data, + key: key, + }; + }); + + const preparedPlotLinesData = high_dose_irrigation_days.map((dateStr, index) => { + const timestamp = dayjs(dateStr).valueOf(); + + const plotLineProps: Highcharts.XAxisPlotLinesOptions = { + value: timestamp, + color: '#FF0000', + dashStyle: 'Solid', + width: 2, + zIndex: 5, + label: index === 0 ? { + text: 'High Dose Irrigation', + rotation: 90, + align: 'left', + textAlign: 'center', + verticalAlign: 'top', + style: { + color: '#FF0000', + fontWeight: 'bold', + }, + x: 5 + } : undefined + }; + return plotLineProps; + }); + + return { seriesData: preparedSeriesData, plotLinesData: preparedPlotLinesData }; + + }, [datapointsResponse]); + + + const chartReady = !datapointsLoading && datapointsResponse && seriesData.length > 0; return ( @@ -64,63 +102,54 @@ const SoilMoistureAnalysisPage = () => { - {analysisLoading || datasetLoading && } - {!(analysisLoading || datasetLoading) && datasetResponse - && - - // dateFormatter.format(new Date(date)), - // label: 'Timeline', - // }, - // ]} - // yAxis={[ - // { - // label: 'Soil moisture depth (%)', - // }, - // ]} - // series={[ - // { dataKey: 'soil_moisture_10', label: 'Soil Moisture 10', showMark: false, color: theme.palette.primary.light }, - // { dataKey: 'soil_moisture_20', label: 'Soil Moisture 20', showMark: false, color: theme.palette.secondary.dark }, - // { dataKey: 'soil_moisture_30', label: 'Soil Moisture 30', showMark: false, color: theme.palette.secondary.main }, - // { dataKey: 'soil_moisture_40', label: 'Soil Moisture 40', showMark: false, color: theme.palette.secondary.light }, - // { dataKey: 'soil_moisture_50', label: 'Soil Moisture 50', showMark: false, color: theme.palette.primary.main }, - // { dataKey: 'soil_moisture_60', label: 'Soil Moisture 60', showMark: false, color: theme.palette.primary.dark }, - // ]} - // height={300} - // /> - } + {datapointsLoading && } + + {chartReady && ( + + + {plotLinesData.map((plotLineProps, index) => ( + + ))} + + + + + + + + + {seriesData.map(series => ( + + ))} + + )} + + {!datapointsLoading && !datapointsResponse && ( + + Please select a dataset to load soil moisture readings. + + )}
- {!(analysisLoading || datasetLoading) && analysisResponse && - <> - - - {/* High dose irrigation event dates */} - - {/* Hardcoded */} - Irrigation events: 2 - estimated dates: 25/6/2025, 30/8/2025 - Precipitation events: 6 - estimated dates: 7/6/2025, 8/6/2025, 13/7/2025-25/7/2025 - High dose irrigation events: 1 - estimated dates: 25/6/2025 - Stress level events: 12 - estimated dates: 26/5/2025-6/6/2025, 8/7/2025 - {/* Hardcoded */} - {/* {analysisResponse.high_dose_irrigation_events_dates.map(d => { - return
{dayjs(d).format('dddd, D/MMM/YYYY')}
- })} */} -
-
-
- - } + ) } From b0dba31f5502a8e4ebd99e30b7c2fe4392e901db Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 19 Dec 2025 12:59:48 +0100 Subject: [PATCH 38/91] installed dayjs utc plugin --- package-lock.json | 21 +++++++++++++++++---- package.json | 3 ++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4e27138..72b69a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "@mui/x-charts": "^8.11.1", "@mui/x-date-pickers": "^8.10.2", "@toolpad/core": "^0.16.0", - "dayjs": "^1.11.13", + "dayjs": "^1.11.19", + "dayjs-plugin-utc": "^0.1.2", "highcharts": "^12.4.0", "jwt-decode": "^4.0.0", "react": "^19.0.0", @@ -3515,6 +3516,12 @@ } } }, + "node_modules/@toolpad/core/node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/@toolpad/utils": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@toolpad/utils/-/utils-0.16.0.tgz", @@ -4994,9 +5001,15 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/dayjs-plugin-utc": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dayjs-plugin-utc/-/dayjs-plugin-utc-0.1.2.tgz", + "integrity": "sha512-ExERH5o3oo6jFOdkvMP3gytTCQ9Ksi5PtylclJWghr7k7m3o2U5QrwtdiJkOxLOH4ghr0EKhpqGefzGz1VvVJg==", "license": "MIT" }, "node_modules/debug": { diff --git a/package.json b/package.json index 8e16b00..f9f8e0a 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "@mui/x-charts": "^8.11.1", "@mui/x-date-pickers": "^8.10.2", "@toolpad/core": "^0.16.0", - "dayjs": "^1.11.13", + "dayjs": "^1.11.19", + "dayjs-plugin-utc": "^0.1.2", "highcharts": "^12.4.0", "jwt-decode": "^4.0.0", "react": "^19.0.0", From 68ec89d9f7523e584bdb69f63e236011ae782a49 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Mon, 22 Dec 2025 13:31:10 +0100 Subject: [PATCH 39/91] impl soil moisture graph display using highcharts and card with high dose irrigation days info --- .../Irrigation/SoilMoistureAnalysis.tsx | 170 +++++++++--------- 1 file changed, 85 insertions(+), 85 deletions(-) diff --git a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx index 5c6370d..7dafff8 100644 --- a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx +++ b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx @@ -1,13 +1,21 @@ import { useMemo, useState, useEffect } from 'react'; +import { + Chart, + Series, + Title, + Tooltip, + YAxis +} from '@highcharts/react'; import * as Highcharts from 'highcharts'; -import { Chart, XAxis, YAxis, Tooltip, Legend } from '@highcharts/react'; -import { Line } from '@highcharts/react/series'; import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; import useFetch from "@hooks/useFetch"; -import { DatasetResponse, DatasetRow } from "@models/SoilMoisture"; +import { DatasetResponse, DatasetRow } from "@models/SoilMoisture"; import { Box, Card, CardContent, Skeleton, Typography } from "@mui/material"; import dayjs from "dayjs"; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); const SoilMoistureAnalysisPage = () => { const [selectedDataset, setSelectedDataset] = useState(''); @@ -25,22 +33,22 @@ const SoilMoistureAnalysisPage = () => { } }, [selectedDataset]) - const { seriesData, plotLinesData } = useMemo(() => { + const { seriesData, highDoseDays } = useMemo(() => { if (!datapointsResponse || datapointsResponse.data_points.length === 0) { - return { seriesData: [], plotLinesData: [] }; + return { seriesData: [], highDoseDays: [] }; } const { data_points, high_dose_irrigation_days } = datapointsResponse; - + const soilMoistureKeys = ['soil_moisture_10', 'soil_moisture_20', 'soil_moisture_30', 'soil_moisture_40', 'soil_moisture_50', 'soil_moisture_60']; - + const preparedSeriesData = soilMoistureKeys.map(key => { const depth = key.split('_')[2]; - + const data = data_points .map(d => { - const timestamp = dayjs(d.date).valueOf(); - const value = d[key as keyof DatasetRow] as number; + const timestamp = dayjs.utc(d.date).valueOf(); + const value = d[key as keyof DatasetRow] as number; return [timestamp, value] as [number, number]; }) .filter(([_ts, val]) => val !== 0) as [number, number][]; @@ -52,35 +60,24 @@ const SoilMoistureAnalysisPage = () => { }; }); - const preparedPlotLinesData = high_dose_irrigation_days.map((dateStr, index) => { - const timestamp = dayjs(dateStr).valueOf(); - + const preparedhighDoseDays = high_dose_irrigation_days.map((dateStr) => { + const timestamp = dayjs.utc(dateStr).valueOf(); + const plotLineProps: Highcharts.XAxisPlotLinesOptions = { value: timestamp, color: '#FF0000', - dashStyle: 'Solid', - width: 2, - zIndex: 5, - label: index === 0 ? { - text: 'High Dose Irrigation', - rotation: 90, - align: 'left', - textAlign: 'center', - verticalAlign: 'top', - style: { - color: '#FF0000', - fontWeight: 'bold', - }, - x: 5 - } : undefined + dashStyle: 'LongDash', + width: 1, + zIndex: -5, }; return plotLineProps; }); - return { seriesData: preparedSeriesData, plotLinesData: preparedPlotLinesData }; + return { seriesData: preparedSeriesData, highDoseDays: preparedhighDoseDays }; }, [datapointsResponse]); + console.log(seriesData); const chartReady = !datapointsLoading && datapointsResponse && seriesData.length > 0; @@ -89,67 +86,70 @@ const SoilMoistureAnalysisPage = () => { - - Select a dataset to see its soil moisture analysis - - - endpoint='proxy/irrigation/api/v1/dataset/' - label='Datasets' - selectedValue={selectedDataset} - setSelectedValue={setSelectedDataset} - getOptionLabel={item => item} - getOptionValue={item => item}> - - - - {datapointsLoading && } - - {chartReady && ( - - - {plotLinesData.map((plotLineProps, index) => ( - + + + Select a dataset to see its soil moisture analysis + + + endpoint='proxy/irrigation/api/v1/dataset/' + label='Datasets' + selectedValue={selectedDataset} + setSelectedValue={setSelectedDataset} + getOptionLabel={item => item} + getOptionValue={item => item}> + + + + {datapointsLoading && } + + {chartReady && ( + + Soil Moisture Analysis and Irrigation Events + {seriesData.map(series => ( + ))} - - - - - - - - - {seriesData.map(series => ( - + - ))} - - )} - - {!datapointsLoading && !datapointsResponse && ( - - Please select a dataset to load soil moisture readings. - - )} + + )} + + {!datapointsLoading && !datapointsResponse && ( + + Please select a dataset to load soil moisture readings. + + )} + - + {chartReady && + <> + + + + High dose irrigation days: {highDoseDays.length} + + Estimated dates:  + {highDoseDays.map((d, index) => { + return dayjs(d.value).format('dddd MMM D') + `${(index < highDoseDays.length - 1) ? ', ' : ''}` + })} + + + + + + } ) } From 8e99af6106c49db327c858759ca1c9a87fc837d9 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 16 Jan 2026 14:04:14 +0100 Subject: [PATCH 40/91] impl field capacity, stress level, and wilting point plot lines --- src/models/SoilMoisture.ts | 5 +- .../Irrigation/SoilMoistureAnalysis.tsx | 74 +++++++++++++++---- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/src/models/SoilMoisture.ts b/src/models/SoilMoisture.ts index fd6c05f..a5f392f 100644 --- a/src/models/SoilMoisture.ts +++ b/src/models/SoilMoisture.ts @@ -16,7 +16,10 @@ export interface DatasetRow { export interface DatasetResponse { data_points: DatasetRow[]; - high_dose_irrigation_days: string[] + high_dose_irrigation_days: string[]; + field_capacity: number; + stress_level: number; + wilting_point: number; } export interface SoilMoistureResponseJSONLD { diff --git a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx index 7dafff8..5f3895c 100644 --- a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx +++ b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx @@ -3,8 +3,7 @@ import { Chart, Series, Title, - Tooltip, - YAxis + Tooltip } from '@highcharts/react'; import * as Highcharts from 'highcharts'; @@ -33,12 +32,14 @@ const SoilMoistureAnalysisPage = () => { } }, [selectedDataset]) - const { seriesData, highDoseDays } = useMemo(() => { + const { seriesData, highDoseDays, horizontalPlotLines } = useMemo(() => { if (!datapointsResponse || datapointsResponse.data_points.length === 0) { - return { seriesData: [], highDoseDays: [] }; + return { seriesData: [], highDoseDays: [], horizontalPlotLines: [] }; } - const { data_points, high_dose_irrigation_days } = datapointsResponse; + const { data_points, high_dose_irrigation_days, field_capacity, stress_level, wilting_point } = datapointsResponse; + + console.log(field_capacity, stress_level, wilting_point); const soilMoistureKeys = ['soil_moisture_10', 'soil_moisture_20', 'soil_moisture_30', 'soil_moisture_40', 'soil_moisture_50', 'soil_moisture_60']; @@ -60,6 +61,28 @@ const SoilMoistureAnalysisPage = () => { }; }); + const thresholds = [ + { value: field_capacity * 100, label: 'Field capacity' }, + { value: stress_level * 100, label: 'Stress level' }, + { value: wilting_point * 100, label: 'Wilting point' } + ]; + + const preparedThresholdLines = thresholds.map((t) => { + const plotLine: Highcharts.YAxisPlotLinesOptions = { + value: t.value, + color: 'black', + dashStyle: 'Solid', + width: 2, + zIndex: 3, + label: { + text: t.label + } + } + return plotLine; + }); + + console.log(preparedThresholdLines); + const preparedhighDoseDays = high_dose_irrigation_days.map((dateStr) => { const timestamp = dayjs.utc(dateStr).valueOf(); @@ -73,7 +96,7 @@ const SoilMoistureAnalysisPage = () => { return plotLineProps; }); - return { seriesData: preparedSeriesData, highDoseDays: preparedhighDoseDays }; + return { seriesData: preparedSeriesData, highDoseDays: preparedhighDoseDays, horizontalPlotLines: preparedThresholdLines }; }, [datapointsResponse]); @@ -103,7 +126,15 @@ const SoilMoistureAnalysisPage = () => { {datapointsLoading && } {chartReady && ( - + Soil Moisture Analysis and Irrigation Events {seriesData.map(series => ( { /> ))} - { - High dose irrigation days: {highDoseDays.length} - - Estimated dates:  - {highDoseDays.map((d, index) => { - return dayjs(d.value).format('dddd MMM D') + `${(index < highDoseDays.length - 1) ? ', ' : ''}` - })} - + + Thresholds: + + Field capacity: {Math.round(Number(horizontalPlotLines[0].value))}% + + + Stress level: {Math.round(Number(horizontalPlotLines[1].value))}% + + + Wilting point: {Math.round(Number(horizontalPlotLines[2].value))}% + + + + High dose irrigation days: {highDoseDays.length} + + Estimated dates:  + {highDoseDays.map((d, index) => { + return dayjs(d.value).format('dddd MMM D') + `${(index < highDoseDays.length - 1) ? ', ' : ''}` + })} + + From 586766f141af8f383744aafa1d92d12d0ada0410 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 20 Jan 2026 16:16:24 +0100 Subject: [PATCH 41/91] impl soil type to soil moisture analysis --- .../Irrigation/SoilMoistureAnalysis.tsx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx index 5f3895c..0f66609 100644 --- a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx +++ b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx @@ -18,9 +18,10 @@ dayjs.extend(utc); const SoilMoistureAnalysisPage = () => { const [selectedDataset, setSelectedDataset] = useState(''); + const [selectedSoil, setSelectedSoil] = useState(''); const { fetchData: datapointsFetchData, loading: datapointsLoading, response: datapointsResponse } = useFetch( - `proxy/irrigation/api/v1/dataset/${selectedDataset}/irrigation-datapoints/?formatting=JSON`, + `proxy/irrigation/api/v1/dataset/${selectedDataset}/irrigation-datapoints/?formatting=JSON${selectedSoil && '&soil=' + selectedSoil}`, { method: 'GET' } @@ -32,6 +33,14 @@ const SoilMoistureAnalysisPage = () => { } }, [selectedDataset]) + useEffect(() => { + if (selectedSoil) { + if (selectedDataset) { + datapointsFetchData(); + } + } + }, [selectedSoil]) + const { seriesData, highDoseDays, horizontalPlotLines } = useMemo(() => { if (!datapointsResponse || datapointsResponse.data_points.length === 0) { return { seriesData: [], highDoseDays: [], horizontalPlotLines: [] }; @@ -111,7 +120,7 @@ const SoilMoistureAnalysisPage = () => { - Select a dataset to see its soil moisture analysis + Select a dataset to see its soil moisture analysis. Also select a soil type to dynamically adjust the thresholds endpoint='proxy/irrigation/api/v1/dataset/' @@ -121,6 +130,15 @@ const SoilMoistureAnalysisPage = () => { getOptionLabel={item => item} getOptionValue={item => item}> + + endpoint='proxy/irrigation/api/v1/dataset/soil-types/' + method="GET" + label='Soil type' + selectedValue={selectedSoil} + setSelectedValue={setSelectedSoil} + getOptionLabel={item => item} + getOptionValue={item => item} + /> {datapointsLoading && } From 3a62a81ba0f09502b9dbef7fd196ffe95088e1c8 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 30 Jan 2026 12:09:59 +0100 Subject: [PATCH 42/91] impl crop type and stages dropdowns to ETo --- .../services/Irrigation/EToCalculator.tsx | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/pages/dashboard/services/Irrigation/EToCalculator.tsx b/src/pages/dashboard/services/Irrigation/EToCalculator.tsx index 142632d..bbc272c 100644 --- a/src/pages/dashboard/services/Irrigation/EToCalculator.tsx +++ b/src/pages/dashboard/services/Irrigation/EToCalculator.tsx @@ -14,6 +14,7 @@ import { LineChart } from "@mui/x-charts"; import CalculateIcon from '@mui/icons-material/Calculate'; import ParcelSelectionModule from "@components/dashboard/ParcelSelectionModule/ParcelSelectionModule"; import { useSession } from "@contexts/SessionContext"; +import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; const EToCalculatorPage = () => { @@ -24,13 +25,16 @@ const EToCalculatorPage = () => { const [fromDate, setFromDate] = useState(dayjs().subtract(14, 'days')); const [toDate, setToDate] = useState(dayjs()); const [calculations, setSetCalculations] = useState(undefined); + const [selectedCropType, setSelectedCropType] = useState(''); + const [selectedCropStage, setSelectedCropStage] = useState(''); + const [chartData, setChartData] = useState< { x: Date; y: number; }[] | undefined>(undefined); const { fetchData, response, error, loading } = useFetch( - `proxy/irrigation/api/v1/eto/calculate-gk/?parcel_id=${session?.farm_parcel?.["@id"].split(":")[3]}&from_date=${fromDate?.format('YYYY-MM-DD')}&to_date=${toDate?.format('YYYY-MM-DD')}&formatting=JSON`, + `proxy/irrigation/api/v1/eto/calculate-gk/?parcel_id=${session?.farm_parcel?.["@id"].split(":")[3]}&from_date=${fromDate?.format('YYYY-MM-DD')}&to_date=${toDate?.format('YYYY-MM-DD')}&formatting=JSON${selectedCropType && '&crop=' + selectedCropType}${selectedCropStage && '&stage=' + selectedCropStage}`, // `proxy/irrigation/api/v1/eto/get-calculations/${selectedLocation}/from/${fromDate?.format('YYYY-MM-DD')}/to/${toDate?.format('YYYY-MM-DD')}/?response_format=JSON-LD/`, { method: 'GET', @@ -64,7 +68,7 @@ const EToCalculatorPage = () => { })); setChartData(chartData); } - }, [calculations]) + }, [calculations]) return ( <> @@ -73,17 +77,28 @@ const EToCalculatorPage = () => { - Select a location and a time frame to view its ETo calculation. + Select a location and a time frame to view its ETo calculation. Additionally select the crop type and growth stage for finer tuning. - {/* - endpoint='proxy/irrigation/api/v1/location/' - label='Locations' - transformResponse={response => response.locations} - selectedValue={selectedLocation} - setSelectedValue={setSelectedLocation} - getOptionLabel={item => `(${item.longitude}, ${item.latitude})`} - getOptionValue={item => item.id}> - */} + + endpoint='proxy/irrigation/api/v1/eto/option-types/' + method="GET" + label='Crop type' + selectedValue={selectedCropType} + transformResponse={response => response.crops} + setSelectedValue={setSelectedCropType} + getOptionLabel={item => item} + getOptionValue={item => item} + /> + + endpoint='proxy/irrigation/api/v1/eto/option-types/' + method="GET" + label='Stages' + selectedValue={selectedCropStage} + transformResponse={response => response.stages} + setSelectedValue={setSelectedCropStage} + getOptionLabel={item => item} + getOptionValue={item => item} + /> { loadingPosition="start" onClick={handleSubmit} // disabled={!selectedLocation || !fromDate || !toDate} - disabled={!fromDate || !toDate} + disabled={!fromDate || !toDate || Boolean(selectedCropType && !selectedCropStage) || Boolean(selectedCropStage && !selectedCropType)} variant="contained"> Get calculations From adaa064f7b8a6e375fc08defb73b904b7a2d0726 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Thu, 26 Feb 2026 17:31:31 +0100 Subject: [PATCH 43/91] fixed soil graph not displaying horizontal plot lines in certain cases --- .../Irrigation/SoilMoistureAnalysis.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx index 0f66609..81857cf 100644 --- a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx +++ b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx @@ -84,7 +84,14 @@ const SoilMoistureAnalysisPage = () => { width: 2, zIndex: 3, label: { - text: t.label + text: t.label, + align: 'right', + x: -10, + style: { + fontSize: '10px', + fontWeight: 'bold' + }, + y: t.label === 'Stress level' ? 0 : (t.label === 'Field capacity' ? -10 : 10) } } return plotLine; @@ -151,7 +158,13 @@ const SoilMoistureAnalysisPage = () => { backgroundColor: 'transparent' }, xAxis: { type: 'datetime', plotLines: highDoseDays }, - yAxis: { title: { text: 'Soil Moisture (%)' }, plotLines: horizontalPlotLines }, + yAxis: { + title: { text: 'Soil Moisture (%)' }, + plotLines: horizontalPlotLines, + min: 0, + startOnTick: false, + endOnTick: false + }, }}> Soil Moisture Analysis and Irrigation Events {seriesData.map(series => ( From 3d4fdbdf9b800f40cd2038c77240c7c3d51aef8e Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Wed, 11 Mar 2026 16:22:46 +0100 Subject: [PATCH 44/91] implemented soil moisture dataset deletion --- .../Irrigation/SoilMoistureAnalysis.tsx | 272 +++++++++++------- 1 file changed, 172 insertions(+), 100 deletions(-) diff --git a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx index 81857cf..6bfb166 100644 --- a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx +++ b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx @@ -10,10 +10,16 @@ import * as Highcharts from 'highcharts'; import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; import useFetch from "@hooks/useFetch"; import { DatasetResponse, DatasetRow } from "@models/SoilMoisture"; -import { Box, Card, CardContent, Skeleton, Typography } from "@mui/material"; +import { Box, Button, Card, CardContent, Skeleton, Typography } from "@mui/material"; import dayjs from "dayjs"; import utc from 'dayjs/plugin/utc'; +import DeleteIcon from '@mui/icons-material/Delete'; +import useDialog from '@hooks/useDialog'; +import GenericDialog from '@components/shared/GenericDialog/GenericDialog'; +import GenericSnackbar from '@components/shared/GenericSnackbar/GenericSnackbar'; +import useSnackbar from '@hooks/useSnackbar'; + dayjs.extend(utc); const SoilMoistureAnalysisPage = () => { @@ -41,6 +47,43 @@ const SoilMoistureAnalysisPage = () => { } }, [selectedSoil]) + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + + const { dialogProps, showDialog } = useDialog(); + + const handleCloseDialog = () => { + dialogProps.onClose(); + }; + + const { fetchData: deleteFetchData, response: deleteResponse, error: deleteError } = useFetch( // TODO: add loading and add loading handling in generic yes-no dialog + `proxy/irrigation/api/v1/dataset/${selectedDataset}/`, + { + method: 'DELETE' + } + ); + + const handleDelete = () => { + console.log("dataset to delete:", selectedDataset); + deleteFetchData(); + }; + + const [refreshKey, setRefreshKey] = useState(0); + + useEffect(() => { + if (deleteResponse) { + setSelectedDataset(''); + setRefreshKey(prev => prev + 1); + showSnackbar('success', "Successfully deleted dataset"); + } + }, [deleteResponse]); + + useEffect(() => { + if (deleteError) { + showSnackbar('error', "An error occurred while deleting the dataset"); + } + }, [deleteError]); + const { seriesData, highDoseDays, horizontalPlotLines } = useMemo(() => { if (!datapointsResponse || datapointsResponse.data_points.length === 0) { return { seriesData: [], highDoseDays: [], horizontalPlotLines: [] }; @@ -91,7 +134,7 @@ const SoilMoistureAnalysisPage = () => { fontSize: '10px', fontWeight: 'bold' }, - y: t.label === 'Stress level' ? 0 : (t.label === 'Field capacity' ? -10 : 10) + y: t.label === 'Stress level' ? 0 : (t.label === 'Field capacity' ? -10 : 10) } } return plotLine; @@ -121,111 +164,140 @@ const SoilMoistureAnalysisPage = () => { const chartReady = !datapointsLoading && datapointsResponse && seriesData.length > 0; return ( - - - - + <> + + + - - Select a dataset to see its soil moisture analysis. Also select a soil type to dynamically adjust the thresholds - - - endpoint='proxy/irrigation/api/v1/dataset/' - label='Datasets' - selectedValue={selectedDataset} - setSelectedValue={setSelectedDataset} - getOptionLabel={item => item} - getOptionValue={item => item}> - - - endpoint='proxy/irrigation/api/v1/dataset/soil-types/' - method="GET" - label='Soil type' - selectedValue={selectedSoil} - setSelectedValue={setSelectedSoil} - getOptionLabel={item => item} - getOptionValue={item => item} - /> - - - {datapointsLoading && } + + + Select a dataset to see its soil moisture analysis. Also select a soil type to dynamically adjust the thresholds + + + + key={refreshKey} + endpoint='proxy/irrigation/api/v1/dataset/' + label='Datasets' + selectedValue={selectedDataset} + setSelectedValue={setSelectedDataset} + getOptionLabel={item => item} + getOptionValue={item => item}> + + + + + endpoint='proxy/irrigation/api/v1/dataset/soil-types/' + method="GET" + label='Soil type' + selectedValue={selectedSoil} + setSelectedValue={setSelectedSoil} + getOptionLabel={item => item} + getOptionValue={item => item} + /> + + + {datapointsLoading && } - {chartReady && ( - - Soil Moisture Analysis and Irrigation Events - {seriesData.map(series => ( - + Soil Moisture Analysis and Irrigation Events + {seriesData.map(series => ( + + ))} + + - ))} - - - - )} - - {!datapointsLoading && !datapointsResponse && ( - - Please select a dataset to load soil moisture readings. - - )} - - - - - {chartReady && - <> - - - - - Thresholds: - - Field capacity: {Math.round(Number(horizontalPlotLines[0].value))}% - - - Stress level: {Math.round(Number(horizontalPlotLines[1].value))}% - - - Wilting point: {Math.round(Number(horizontalPlotLines[2].value))}% + + )} + + {!datapointsLoading && !datapointsResponse && ( + + Please select a dataset to load soil moisture readings. - + )} + + + + + {selectedDataset && chartReady && + <> + + - High dose irrigation days: {highDoseDays.length} - - Estimated dates:  - {highDoseDays.map((d, index) => { - return dayjs(d.value).format('dddd MMM D') + `${(index < highDoseDays.length - 1) ? ', ' : ''}` - })} - + + Thresholds: + + Field capacity: {Math.round(Number(horizontalPlotLines[0].value))}% + + + Stress level: {Math.round(Number(horizontalPlotLines[1].value))}% + + + Wilting point: {Math.round(Number(horizontalPlotLines[2].value))}% + + + + High dose irrigation days: {highDoseDays.length} + + Estimated dates:  + {highDoseDays.map((d, index) => { + return dayjs(d.value).format('dddd MMM D') + `${(index < highDoseDays.length - 1) ? ', ' : ''}` + })} + + - - - - - } - + + + + } + + + + ) } From bcaaf4686f6693af604187f65d5a3bcddafa7cde Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Wed, 18 Mar 2026 11:31:47 +0100 Subject: [PATCH 45/91] parcel depiction field now nullable --- .../ParcelSelectionList/ParcelSelectionList.tsx | 5 +++-- src/models/FarmParcel.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx index 835a575..08d88da 100644 --- a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx +++ b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx @@ -9,7 +9,8 @@ const ParcelSelectionList: React.FC = ({ parcels, sele return ( parcels.map((p) => { const isSelected = p["@id"] === selectedParcelId; - + const parcelImage = p.depiction?.trim() ?? placeholder + return ( = ({ parcels, sele > Date: Wed, 18 Mar 2026 11:36:21 +0100 Subject: [PATCH 46/91] fixed improper nullish operator usage --- .../ParcelSelectionList/ParcelSelectionList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx index 08d88da..f79670c 100644 --- a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx +++ b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx @@ -9,7 +9,7 @@ const ParcelSelectionList: React.FC = ({ parcels, sele return ( parcels.map((p) => { const isSelected = p["@id"] === selectedParcelId; - const parcelImage = p.depiction?.trim() ?? placeholder + const parcelImage = (p.depiction || "").trim() || placeholder; return ( Date: Thu, 26 Mar 2026 10:26:50 +0100 Subject: [PATCH 47/91] added rbac user data into session --- src/contexts/SessionContext.ts | 9 +++++-- src/layouts/dashboard.tsx | 45 +++++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/contexts/SessionContext.ts b/src/contexts/SessionContext.ts index 269de60..4526c7a 100644 --- a/src/contexts/SessionContext.ts +++ b/src/contexts/SessionContext.ts @@ -1,12 +1,17 @@ import { FarmParcelModel } from "@models/FarmParcel"; import { createContext, useContext } from "react"; +export type ServiceActions = 'add' | 'delete' | 'edit' | 'view'; export interface Session { user: { - token?: string - refresh_token?: string + token?: string; + refresh_token?: string; }; farm_parcel?: FarmParcelModel; + services?: { + code: string; + actions: ServiceActions[]; + }[] } interface SessionContextType { diff --git a/src/layouts/dashboard.tsx b/src/layouts/dashboard.tsx index 8335a98..bfd924f 100644 --- a/src/layouts/dashboard.tsx +++ b/src/layouts/dashboard.tsx @@ -1,4 +1,4 @@ -import { Outlet, useLocation } from 'react-router'; +import { Outlet, useLocation, useNavigate } from 'react-router'; import { DashboardLayout } from '@toolpad/core/DashboardLayout'; import { Breadcrumb, PageContainer } from '@toolpad/core/PageContainer'; import ToolbarActions from '@components/dashboard/ToolbarActions/ToolbarActions'; @@ -6,7 +6,8 @@ import { useSession } from '@contexts/SessionContext'; import Redirect from '@components/shared/Redirect/Redirect'; import { jwtDecode } from 'jwt-decode'; import Footer from '@components/shared/Footer'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import useFetch from '@hooks/useFetch'; export type DashboardContextType = { setPageTitle: (title: string | undefined) => void; @@ -14,9 +15,47 @@ export type DashboardContextType = { }; export default function DashLayout() { - const { session } = useSession() + const { session, setSession } = useSession(); + const navigate = useNavigate(); const location = useLocation(); + const { fetchData, response, error } = useFetch( + "me/", + { + method: 'GET', + } + ); + + useEffect(() => { + if (session) { + console.log("in session fetch", session); + + // fetchData(); + } + }, []); + + + useEffect(() => { + if (session && response) { + setSession(prevSession => { + if (prevSession) { + return { + ...prevSession, + services: response.services + }; + } + return null; + }); + } + }, [response]) + + useEffect(() => { + if (error) { + setSession(null); + navigate("/"); + } + }, [error]) + const callbackURL = `?callbackURL=${encodeURIComponent(location.pathname)}`; From fbd78369ba811b940f13d1457fe12717f1e77f3d Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Wed, 1 Apr 2026 12:45:36 +0200 Subject: [PATCH 48/91] fixed rendering order of state obejcts in dashboard layout --- src/layouts/dashboard.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/layouts/dashboard.tsx b/src/layouts/dashboard.tsx index bfd924f..fd9c055 100644 --- a/src/layouts/dashboard.tsx +++ b/src/layouts/dashboard.tsx @@ -19,6 +19,9 @@ export default function DashLayout() { const navigate = useNavigate(); const location = useLocation(); + const [pageTitle, setPageTitle] = useState(undefined); + const [breadcrumbs, setBreadcrumbs] = useState(undefined); + const { fetchData, response, error } = useFetch( "me/", { @@ -28,13 +31,10 @@ export default function DashLayout() { useEffect(() => { if (session) { - console.log("in session fetch", session); - - // fetchData(); + fetchData(); } }, []); - useEffect(() => { if (session && response) { setSession(prevSession => { @@ -76,9 +76,6 @@ export default function DashLayout() { } } - const [pageTitle, setPageTitle] = useState(undefined); - const [breadcrumbs, setBreadcrumbs] = useState(undefined); - return ( Date: Fri, 3 Apr 2026 15:32:55 +0200 Subject: [PATCH 49/91] Implemented farm and farm parcel addition --- .../FarmLocations/AddFarm/AddFarm.tsx | 166 +++++++++++++++ .../FarmLocations/AddFarm/AddFarm.types.ts | 3 + .../AddFarmParcel/AddFarmParcel.tsx | 196 ++++++++++++++++++ .../AddFarmParcel/AddFarmParcel.types.ts | 3 + .../FarmLocations/FarmParcels.tsx | 29 ++- .../FarmCalendar/FarmLocations/Farms.tsx | 28 ++- 6 files changed, 419 insertions(+), 6 deletions(-) create mode 100644 src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarm/AddFarm.tsx create mode 100644 src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarm/AddFarm.types.ts create mode 100644 src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx create mode 100644 src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.types.ts diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarm/AddFarm.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarm/AddFarm.tsx new file mode 100644 index 0000000..073de78 --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarm/AddFarm.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from "react"; +import { AddFarmProps } from "./AddFarm.types"; +import { FarmModel } from "@models/Farm"; +import { Box, Button, Stack, TextField } from "@mui/material"; +import AddIcon from '@mui/icons-material/Add'; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; + +const AddFarm: React.FC = ({ onAction }) => { + const [formData, setFormData] = useState({ + '@type': '', + '@id': '', + status: 0, + deleted_at: null, + created_at: '', + updated_at: '', + name: '', + description: '', + administrator: '', + telephone: '', + vatID: '', + hasAgriParcel: [], + contactPerson: { + firstname: '', + lastname: '', + '@id': '', + '@type': '', + }, + address: { + '@id': '', + '@type': '', + adminUnitL1: '', + adminUnitL2: '', + addressArea: '', + municipality: '', + community: '', + locatorName: '', + } + }); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + const keys = name.split('.'); + + setFormData(prev => { + const newState = JSON.parse(JSON.stringify(prev)) as FarmModel; + + let currentLevel: any = newState; + try { + for (let i = 0; i < keys.length - 1; i++) { + currentLevel = currentLevel[keys[i]]; + } + const finalKey = keys[keys.length - 1]; + const isNumeric = typeof currentLevel[finalKey] === 'number'; + currentLevel[finalKey] = isNumeric ? parseFloat(value) || 0 : value; + + } catch (error) { + console.error(`Error setting nested property "${name}":`, error); + return prev; + } + return newState; + }); + }; + + const { fetchData, response, error, loading } = useFetch( + `proxy/farmcalendar/api/v1/Farm/`, + { + method: 'POST', + body: formData + } + ); + + const handlePost = () => { + console.log("Form Data:", formData); + fetchData(); + }; + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + + + useEffect(() => { + if (response) { + onAction && onAction(); + showSnackbar('success', "Pest added successfully"); + } + }, [response]); + + useEffect(() => { + if (error) { + showSnackbar('error', "An error occurred"); + } + }, [error]); + + const isFormInvalid = + !formData?.name?.trim() || + !formData.description?.trim() || + !formData.administrator?.trim() || + !formData.contactPerson.firstname?.trim() || + !formData.contactPerson.lastname?.trim() || + !formData.telephone?.trim() || + !formData.vatID?.trim() || + !formData.address.adminUnitL1?.trim() || + !formData.address.adminUnitL2?.trim() || + !formData.address.addressArea?.trim() || + !formData.address.municipality?.trim() || + !formData.address.community?.trim() || + !formData.address.locatorName?.trim(); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default AddFarm; \ No newline at end of file diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarm/AddFarm.types.ts b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarm/AddFarm.types.ts new file mode 100644 index 0000000..0c40696 --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarm/AddFarm.types.ts @@ -0,0 +1,3 @@ +export interface AddFarmProps { + onAction?: () => void +} \ No newline at end of file diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx new file mode 100644 index 0000000..57ce8a5 --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx @@ -0,0 +1,196 @@ +import { Box, Button, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material"; +import { AddFarmParcelProps } from "./AddFarmParcel.types"; +import { useEffect, useState } from "react"; +import { FarmParcelModel } from "@models/FarmParcel"; +import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; +import { FarmModel } from "@models/Farm"; +import AddIcon from '@mui/icons-material/Add'; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; + +const AddFarmParcel: React.FC = ({ onAction }) => { + + const [selectedFarm, setSelectedFarm] = useState(''); + + const [formData, setFormData] = useState({ + '@type': '', + '@id': '', + 'status': 0, + 'deleted_at': null, + 'created_at': '', + 'updated_at': '', + 'identifier': '', + 'description': '', + 'validFrom': new Date().toISOString(), + 'validTo': new Date().toISOString(), + 'area': '', + 'hasIrrigationFlow': '', + 'category': '', + 'inRegion': '', + 'hasToponym': '', + 'isNitroArea': false, + 'isNatura2000Area': false, + 'isPdopgArea': false, + 'isIrrigated': false, + 'isCultivatedInLevels': false, + 'isGroundSlope': false, + 'depiction': null, + 'hasGeometry': { + '@id': '', + '@type': '', + 'asWKT': 'POLYGON((0 0, 0 0))' + }, + 'location': { + '@id': '', + '@type': '', + 'lat': null, + 'long': null + }, + 'hasAgriCrop': [], + 'farm': { + '@type': 'Farm', + '@id': '', + } + }); + + const { fetchData, response, error, loading } = useFetch( + `proxy/farmcalendar/api/v1/FarmParcels/`, + { + method: 'POST' + } + ); + + const handlePost = () => { + let body = { ...formData }; + (body.farm as { '@id': string })['@id'] = `urn:farmcalendar:Farm:${selectedFarm}`; + + console.log("Form Data:", body); + + fetchData({ body: body }); + }; + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + + + useEffect(() => { + if (response) { + onAction && onAction(); + showSnackbar('success', "Pest added successfully"); + } + }, [response]); + + useEffect(() => { + if (error) { + showSnackbar('error', "An error occurred"); + } + }, [error]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value, type } = e.target; + const checked = (e.target as HTMLInputElement).checked; + const keys = name.split('.'); + + const isNumericField = + name === 'area' || + name === 'hasIrrigationFlow' || + name === 'location.lat' || + name === "location.long"; + + setFormData(prev => { + if (!prev) return undefined; + + const newState = JSON.parse(JSON.stringify(prev)) as FarmParcelModel; + let currentLevel: any = newState; + for (let i = 0; i < keys.length - 1; i++) { + currentLevel = currentLevel[keys[i]]; + } + + let finalValue: string | number | boolean = type === 'checkbox' ? checked : value; + if (isNumericField) { + finalValue = parseFloat(value); + } + + currentLevel[keys[keys.length - 1]] = finalValue; + + return newState; + }); + }; + + const isFormInvalid = + !formData?.identifier?.trim() || + !formData.category || + !formData.location.lat || + !formData.location.long || + !selectedFarm + + return ( + <> + + + + { + endpoint='proxy/farmcalendar/api/v1/Farm/?format=json' + label='Selected farm *' + selectedValue={selectedFarm} + setSelectedValue={setSelectedFarm} + getOptionLabel={item => `${item.name}`} + getOptionValue={item => item["@id"].split(':')[3]}> + } + + + + + + + + + + + + + + + } label="Nitro area" /> + } label="Natura 2000 area" /> + } label="PDOPG area" /> + } label="Irrigated" /> + } label="Cultivated in levels" /> + } label="Ground slope" /> + + + + + + + + + + + + + + + + + + + ) +} + +export default AddFarmParcel; diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.types.ts b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.types.ts new file mode 100644 index 0000000..215cf7c --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.types.ts @@ -0,0 +1,3 @@ +export interface AddFarmParcelProps { + onAction?: () => void +} \ No newline at end of file diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcels.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcels.tsx index 3acfd76..d299c1f 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcels.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcels.tsx @@ -5,14 +5,23 @@ import useFetch from "@hooks/useFetch"; import useSnackbar from "@hooks/useSnackbar"; import { FarmModel } from "@models/Farm"; import { FarmParcelModel } from "@models/FarmParcel"; -import { Skeleton } from "@mui/material"; +import { Accordion, AccordionDetails, AccordionSummary, Box, Skeleton, Typography } from "@mui/material"; import dayjs from "dayjs"; import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; +import AddFarmParcel from "./AddFarmParcel/AddFarmParcel"; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; + const FarmParcelsPage = () => { const [parcels, setParcels] = useState([]); + const [expanded, setExpanded] = useState(false); + + const handleAccordionChange = () => { + setExpanded(!expanded); + }; + const { fetchData, loading, response, error } = useFetch( "proxy/farmcalendar/api/v1/FarmParcels/?format=json", { @@ -78,8 +87,22 @@ const FarmParcelsPage = () => { const handleRowClick = (parcel: ParcelRow) => { navigate(`../farm-parcel/${parcel.id.split(":")[3]}`); }; + + const onAddNewFarmParcel = () => { + fetchData(); + setExpanded(false); + }; + return ( - <> + + + }> + Add new farm parcel + + + + + {loading || farmsLoading && } { !(loading || farmsLoading) && !error && @@ -91,7 +114,7 @@ const FarmParcelsPage = () => { open={snackbarState.open} onClose={closeSnackbar} /> - + ) } diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farms.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farms.tsx index f762a09..3828285 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farms.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farms.tsx @@ -4,13 +4,21 @@ import { HeadCell } from "@components/shared/GenericSortableTable/GenericSortabl import useFetch from "@hooks/useFetch"; import useSnackbar from "@hooks/useSnackbar"; import { FarmModel } from "@models/Farm"; -import { Skeleton } from "@mui/material"; +import { Accordion, AccordionDetails, AccordionSummary, Box, Skeleton, Typography } from "@mui/material"; import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import AddFarm from "./AddFarm/AddFarm"; const FarmsPage = () => { const [farms, setFarms] = useState([]); + const [expanded, setExpanded] = useState(false); + + const handleAccordionChange = () => { + setExpanded(!expanded); + }; + const { fetchData, loading, response, error } = useFetch( "proxy/farmcalendar/api/v1/Farm/?format=json", { @@ -77,8 +85,22 @@ const FarmsPage = () => { const handleRowClick = (farm: FarmRow) => { navigate(`../farm/${farm.id.split(":")[3]}`); }; + + const onAddNewFarm = () => { + fetchData(); + setExpanded(false); + }; + return ( - <> + + + }> + Add new farm + + + + + {loading && } { !loading && !error && @@ -90,7 +112,7 @@ const FarmsPage = () => { open={snackbarState.open} onClose={closeSnackbar} /> - + ) } From 1f91633e17968f227f887c874002412168ae38ff Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 3 Apr 2026 15:50:01 +0200 Subject: [PATCH 50/91] removed markers from soil moisture graph --- .../dashboard/services/Irrigation/SoilMoistureAnalysis.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx index 6bfb166..f957112 100644 --- a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx +++ b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx @@ -221,6 +221,13 @@ const SoilMoistureAnalysisPage = () => { { backgroundColor: 'transparent' }, + plotOptions: { + series: { + marker: { + enabled: false + } + } + }, xAxis: { type: 'datetime', plotLines: highDoseDays }, yAxis: { title: { text: 'Soil Moisture (%)' }, From 431ea45fa5edee5ec8797efaa562c67b24fcc56f Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 14 Apr 2026 12:06:05 +0200 Subject: [PATCH 51/91] disabling routes based on access from read user me --- src/App.tsx | 266 ++++++++++++++++++++------------------ src/layouts/dashboard.tsx | 16 ++- 2 files changed, 154 insertions(+), 128 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index ffaa599..87539fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,7 @@ import WaterDropIcon from '@mui/icons-material/WaterDrop'; import PestControlIcon from '@mui/icons-material/PestControl'; import AssessmentIcon from '@mui/icons-material/Assessment'; // TODO: possibly change to something else import ThermostatIcon from '@mui/icons-material/Thermostat'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import SessionContext, { Session } from '@contexts/SessionContext'; import { Folder } from '@mui/icons-material'; import FenceIcon from '@mui/icons-material/Fence'; @@ -24,130 +24,6 @@ import ReportIcon from '@mui/icons-material/Report'; import CoronavirusIcon from '@mui/icons-material/Coronavirus'; import GrassIcon from '@mui/icons-material/Grass'; -const NAVIGATION: Navigation = [ - { - kind: 'header', - title: 'Home page', - }, - { - segment: '', - title: 'OpenAgri Dashboard', - icon: , - }, - { - kind: 'header', - title: 'Farm calendar', - }, - // { - // segment: 'farm-calendar', - // title: 'Farm calendar', - // icon: , - // children: [ - { - segment: 'farm-calendar', - title: 'Farm calendar', - icon: , - }, - { - segment: 'farm-locations', - title: 'Farm locations', - icon: , - children: [ - { - segment: 'farms', - title: 'Farms', - icon: , - }, - { - segment: 'farm-parcels', - title: 'Farm parcels', - icon: , - } - ] - }, - { - segment: 'reporting-service', - title: 'Reporting service', - icon: , - children: [ - { - segment: 'compost-operations', - title: 'Compost operations', - icon: , - }, - { - segment: 'farm-animals', - title: 'Farm animals', - icon: , - }, - { - segment: 'irrigation-operations', - title: 'Irrigation operations', - icon: , - }, - ] - }, - // ] - // }, - { - kind: 'header', - title: 'Irrigation management', - }, - // { - // segment: 'wkt-input', - // title: 'WKT input', - // icon: , - // }, - { - segment: 'eto-calculator', - title: 'ETo Calculator', - icon: , - }, - { - segment: 'upload-dataset', - title: 'Upload Dataset', - icon: , - }, - { - segment: 'soil-moisture-analysis', - title: 'Soil Moisture Analysis', - icon: , - }, - { - kind: 'header', - title: 'Pest and disease management', - }, - { - segment: 'pests', - title: 'Pests', - icon: , - }, - { - segment: 'gdd', - title: 'Growing degree days', - icon: , - }, - { - segment: 'diseases', - title: 'Diseases', - icon: , - }, - { - segment: 'risk-index', - title: 'Risk index', - icon: , - }, - { - kind: 'header', - title: 'Weather data', - }, - { - segment: 'weather-data', - title: 'Weather data', - icon: , - }, -]; - const BRANDING = { logo: , title: '' @@ -167,8 +43,146 @@ export default function App() { } }, [session]); + const navigation = useMemo(() => { + const hasAccess = (code: string) => { + if (!session || !session.services) return false; + const service = session.services.find(s => s.code === code); + return service ? service.actions.includes('view') : false; + }; + + const nav: any[] = [ + { + kind: 'header', + title: 'Home page', + }, + { + segment: '', + title: 'OpenAgri Dashboard', + icon: , + }, + { + kind: 'header', + title: 'Farm calendar', + }, + { + segment: 'farm-calendar', + title: 'Farm calendar', + icon: , + disabled: !hasAccess('FC'), + }, + { + segment: 'farm-locations', + title: 'Farm locations', + icon: , + disabled: !hasAccess('FC'), + children: [ + { + segment: 'farms', + title: 'Farms', + icon: , + disabled: !hasAccess('FC'), + }, + { + segment: 'farm-parcels', + title: 'Farm parcels', + icon: , + disabled: !hasAccess('FC'), + } + ] + }, + { + segment: 'reporting-service', + title: 'Reporting service', + icon: , + disabled: !hasAccess('RP'), + children: [ + { + segment: 'compost-operations', + title: 'Compost operations', + icon: , + disabled: !hasAccess('RP'), + }, + { + segment: 'farm-animals', + title: 'Farm animals', + icon: , + disabled: !hasAccess('RP'), + }, + { + segment: 'irrigation-operations', + title: 'Irrigation operations', + icon: , + disabled: !hasAccess('RP'), + }, + ] + }, + { + kind: 'header', + title: 'Irrigation management', + }, + { + segment: 'eto-calculator', + title: 'ETo Calculator', + icon: , + disabled: !hasAccess('IRM'), + }, + { + segment: 'upload-dataset', + title: 'Upload Dataset', + icon: , + disabled: !hasAccess('IRM'), + }, + { + segment: 'soil-moisture-analysis', + title: 'Soil Moisture Analysis', + icon: , + disabled: !hasAccess('IRM'), + }, + { + kind: 'header', + title: 'Pest and disease management', + }, + { + segment: 'pests', + title: 'Pests', + icon: , + disabled: !hasAccess('PDM'), + }, + { + segment: 'gdd', + title: 'Growing degree days', + icon: , + disabled: !hasAccess('PDM'), + }, + { + segment: 'diseases', + title: 'Diseases', + icon: , + disabled: !hasAccess('PDM'), + }, + { + segment: 'risk-index', + title: 'Risk index', + icon: , + disabled: !hasAccess('PDM'), + }, + { + kind: 'header', + title: 'Weather data', + }, + { + segment: 'weather-data', + title: 'Weather data', + icon: , + disabled: !hasAccess('WD'), + }, + ]; + + return nav as Navigation; + }, [session]); + return ( - + diff --git a/src/layouts/dashboard.tsx b/src/layouts/dashboard.tsx index fd9c055..54641bd 100644 --- a/src/layouts/dashboard.tsx +++ b/src/layouts/dashboard.tsx @@ -1,12 +1,13 @@ import { Outlet, useLocation, useNavigate } from 'react-router'; -import { DashboardLayout } from '@toolpad/core/DashboardLayout'; +import { DashboardLayout, DashboardSidebarPageItem } from '@toolpad/core/DashboardLayout'; +import type { NavigationPageItem } from '@toolpad/core/AppProvider'; import { Breadcrumb, PageContainer } from '@toolpad/core/PageContainer'; import ToolbarActions from '@components/dashboard/ToolbarActions/ToolbarActions'; import { useSession } from '@contexts/SessionContext'; import Redirect from '@components/shared/Redirect/Redirect'; import { jwtDecode } from 'jwt-decode'; import Footer from '@components/shared/Footer'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import useFetch from '@hooks/useFetch'; export type DashboardContextType = { @@ -56,6 +57,16 @@ export default function DashLayout() { } }, [error]) + const renderPageItem = useCallback( + (item: NavigationPageItem) => { + if ((item as any).disabled) { + return ; + } + return ; + }, + [], + ); + const callbackURL = `?callbackURL=${encodeURIComponent(location.pathname)}`; @@ -78,6 +89,7 @@ export default function DashLayout() { return ( Date: Thu, 16 Apr 2026 11:14:02 +0200 Subject: [PATCH 52/91] implemented layouts to group services and pass user permissions down to components --- src/layouts/services/FarmCalendarLayout.tsx | 17 ++ .../services/IrrigationManagementLayout.tsx | 17 ++ src/layouts/services/PestAndDiseaseLayout.tsx | 17 ++ src/layouts/services/ReportingLayout.tsx | 17 ++ src/layouts/services/WeatherDataLayout.tsx | 17 ++ src/main.tsx | 169 ++++++++++-------- 6 files changed, 178 insertions(+), 76 deletions(-) create mode 100644 src/layouts/services/FarmCalendarLayout.tsx create mode 100644 src/layouts/services/IrrigationManagementLayout.tsx create mode 100644 src/layouts/services/PestAndDiseaseLayout.tsx create mode 100644 src/layouts/services/ReportingLayout.tsx create mode 100644 src/layouts/services/WeatherDataLayout.tsx diff --git a/src/layouts/services/FarmCalendarLayout.tsx b/src/layouts/services/FarmCalendarLayout.tsx new file mode 100644 index 0000000..b5bf2f0 --- /dev/null +++ b/src/layouts/services/FarmCalendarLayout.tsx @@ -0,0 +1,17 @@ +import { Outlet, useOutletContext } from 'react-router'; +import { useSession, ServiceActions } from '@contexts/SessionContext'; +import { DashboardContextType } from '@layouts/dashboard'; + +export type ServiceContextType = DashboardContextType & { + actions: ServiceActions[]; +}; + +export default function FarmCalendarLayout() { + const { session } = useSession(); + const context = useOutletContext(); + + const service = session?.services?.find(s => s.code === 'FC'); + const actions = service?.actions || []; + + return ; +} diff --git a/src/layouts/services/IrrigationManagementLayout.tsx b/src/layouts/services/IrrigationManagementLayout.tsx new file mode 100644 index 0000000..90d7d15 --- /dev/null +++ b/src/layouts/services/IrrigationManagementLayout.tsx @@ -0,0 +1,17 @@ +import { Outlet, useOutletContext } from 'react-router'; +import { useSession, ServiceActions } from '@contexts/SessionContext'; +import { DashboardContextType } from '@layouts/dashboard'; + +export type ServiceContextType = DashboardContextType & { + actions: ServiceActions[]; +}; + +export default function IrrigationManagementLayout() { + const { session } = useSession(); + const context = useOutletContext(); + + const service = session?.services?.find(s => s.code === 'IRM'); + const actions = service?.actions || []; + + return ; +} diff --git a/src/layouts/services/PestAndDiseaseLayout.tsx b/src/layouts/services/PestAndDiseaseLayout.tsx new file mode 100644 index 0000000..1f8e40a --- /dev/null +++ b/src/layouts/services/PestAndDiseaseLayout.tsx @@ -0,0 +1,17 @@ +import { Outlet, useOutletContext } from 'react-router'; +import { useSession, ServiceActions } from '@contexts/SessionContext'; +import { DashboardContextType } from '@layouts/dashboard'; + +export type ServiceContextType = DashboardContextType & { + actions: ServiceActions[]; +}; + +export default function PestAndDiseaseLayout() { + const { session } = useSession(); + const context = useOutletContext(); + + const service = session?.services?.find(s => s.code === 'PDM'); + const actions = service?.actions || []; + + return ; +} diff --git a/src/layouts/services/ReportingLayout.tsx b/src/layouts/services/ReportingLayout.tsx new file mode 100644 index 0000000..ce6a075 --- /dev/null +++ b/src/layouts/services/ReportingLayout.tsx @@ -0,0 +1,17 @@ +import { Outlet, useOutletContext } from 'react-router'; +import { useSession, ServiceActions } from '@contexts/SessionContext'; +import { DashboardContextType } from '@layouts/dashboard'; + +export type ServiceContextType = DashboardContextType & { + actions: ServiceActions[]; +}; + +export default function ReportingLayout() { + const { session } = useSession(); + const context = useOutletContext(); + + const service = session?.services?.find(s => s.code === 'RP'); + const actions = service?.actions || []; + + return ; +} diff --git a/src/layouts/services/WeatherDataLayout.tsx b/src/layouts/services/WeatherDataLayout.tsx new file mode 100644 index 0000000..64107e5 --- /dev/null +++ b/src/layouts/services/WeatherDataLayout.tsx @@ -0,0 +1,17 @@ +import { Outlet, useOutletContext } from 'react-router'; +import { useSession, ServiceActions } from '@contexts/SessionContext'; +import { DashboardContextType } from '@layouts/dashboard'; + +export type ServiceContextType = DashboardContextType & { + actions: ServiceActions[]; +}; + +export default function WeatherDataLayout() { + const { session } = useSession(); + const context = useOutletContext(); + + const service = session?.services?.find(s => s.code === 'WD'); + const actions = service?.actions || []; + + return ; +} diff --git a/src/main.tsx b/src/main.tsx index 1ae679d..febfc86 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -34,6 +34,12 @@ import PestsPage from '@pages/dashboard/services/PestAndDisease/Pests.tsx'; import DiseasesPage from '@pages/dashboard/services/PestAndDisease/Diseases.tsx'; import RiskIndexPage from '@pages/dashboard/services/PestAndDisease/RiskIndex.tsx'; +import FarmCalendarLayout from './layouts/services/FarmCalendarLayout.tsx'; +import IrrigationManagementLayout from './layouts/services/IrrigationManagementLayout.tsx'; +import PestAndDiseaseLayout from './layouts/services/PestAndDiseaseLayout.tsx'; +import ReportingLayout from './layouts/services/ReportingLayout.tsx'; +import WeatherDataLayout from './layouts/services/WeatherDataLayout.tsx'; + const router = createBrowserRouter([ { Component: App, @@ -48,109 +54,120 @@ const router = createBrowserRouter([ }, /** Farm calendar */ { - path: 'farm-calendar', - // children: [ - // { - // index: true, - // element: , - // }, - // /** Farm calendar */ - // { - // path: 'farm-calendar', - Component: FarmCalendarPage, - }, - { - path: 'farm-calendar/register-activity', - Component: RegisterCalendarActivityPage - }, - { - path: 'farm-calendar/edit-activity/:id', - Component: EditCalendarActivityPage - }, - { - path: 'farm-locations', + Component: FarmCalendarLayout, children: [ { - index: true, - element: , + path: 'farm-calendar', + Component: FarmCalendarPage, }, { - path: 'farms', - Component: FarmsPage + path: 'farm-calendar/register-activity', + Component: RegisterCalendarActivityPage }, { - path: 'farm/:id', - Component: FarmPage + path: 'farm-calendar/edit-activity/:id', + Component: EditCalendarActivityPage }, { - path: 'farm-parcels', - Component: FarmParcelsPage - }, - { - path: 'farm-parcel/:id', - Component: FarmParcelPage + path: 'farm-locations', + children: [ + { + index: true, + element: , + }, + { + path: 'farms', + Component: FarmsPage + }, + { + path: 'farm/:id', + Component: FarmPage + }, + { + path: 'farm-parcels', + Component: FarmParcelsPage + }, + { + path: 'farm-parcel/:id', + Component: FarmParcelPage + }, + ] }, ] }, /** End of Farm calendar */ { - path: 'reporting-service', + Component: ReportingLayout, children: [ { - index: true, - element: , + path: 'reporting-service', + children: [ + { + index: true, + element: , + }, + { + path: 'compost-operations', + Component: CompostOperationsReportPage + }, + { + path: 'farm-animals', + Component: FarmAnimalsReportPage + }, + { + path: 'irrigation-operations', + Component: IrrigationOperationsReportPage + }, + ] }, + ] + }, + { + Component: IrrigationManagementLayout, + children: [ { - path: 'compost-operations', - Component: CompostOperationsReportPage + path: 'eto-calculator', + Component: EToCalculatorPage }, { - path: 'farm-animals', - Component: FarmAnimalsReportPage + path: 'upload-dataset', + Component: UploadDatasetPage }, { - path: 'irrigation-operations', - Component: IrrigationOperationsReportPage + path: 'soil-moisture-analysis', + Component: SoilMoistureAnalysisPage }, ] }, - // ] - // }, - // { - // path: 'wkt-input', - // Component: WKTInputPage - // }, - { - path: 'eto-calculator', - Component: EToCalculatorPage - }, { - path: 'upload-dataset', - Component: UploadDatasetPage - }, - { - path: 'soil-moisture-analysis', - Component: SoilMoistureAnalysisPage - }, - { - path: 'pests', - Component: PestsPage - }, - { - path: 'gdd', - Component: GrowingDegreeDaysPage - }, - { - path: 'diseases', - Component: DiseasesPage - }, - { - path: 'risk-index', - Component: RiskIndexPage + Component: PestAndDiseaseLayout, + children: [ + { + path: 'pests', + Component: PestsPage + }, + { + path: 'gdd', + Component: GrowingDegreeDaysPage + }, + { + path: 'diseases', + Component: DiseasesPage + }, + { + path: 'risk-index', + Component: RiskIndexPage + }, + ] }, { - path: 'weather-data', - Component: WeatherDataPage + Component: WeatherDataLayout, + children: [ + { + path: 'weather-data', + Component: WeatherDataPage + }, + ] }, ] }, From ab506746dd31e85dddd1a53bfd8b90f696f8bb39 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Thu, 16 Apr 2026 11:14:38 +0200 Subject: [PATCH 53/91] implemented additional checks to activity crud actions for null values --- .../ActivityDynamicCRUDActions.tsx | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index 3b8cbc0..a029288 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -58,9 +58,11 @@ const ActivityDynamicCRUDActions = ({ activity, act let parcelID: string | undefined; if ('hasAgriParcel' in formData) { - parcelID = (formData as any).hasAgriParcel["@id"]; + if (formData.hasAgriParcel) + parcelID = (formData as any).hasAgriParcel["@id"]; } else if ('operatedOn' in formData) { - parcelID = (formData as any).operatedOn["@id"]; + if (formData.operatedOn) + parcelID = (formData as any).operatedOn["@id"]; } if (parcelID) { const idParts = parcelID.split(':'); @@ -82,7 +84,8 @@ const ActivityDynamicCRUDActions = ({ activity, act let cropID: string | undefined; if ('hasAgriCrop' in formData) { - cropID = (formData as any).hasAgriCrop["@id"]; + if (formData.hasAgriCrop) + cropID = (formData as any).hasAgriCrop["@id"]; } if (cropID) { const idParts = cropID.split(':'); @@ -91,7 +94,8 @@ const ActivityDynamicCRUDActions = ({ activity, act let agriMachinesIDs: string[] | undefined; if ('usesAgriculturalMachinery' in formData) { - agriMachinesIDs = (formData as any).usesAgriculturalMachinery.map((m: any) => { return m["@id"].split(':')[3] }); + if (formData.usesAgriculturalMachinery) + agriMachinesIDs = (formData as any).usesAgriculturalMachinery.map((m: any) => { return m["@id"].split(':')[3] }); } if (agriMachinesIDs) { setSelectedAgriMachines(agriMachinesIDs); @@ -99,7 +103,8 @@ const ActivityDynamicCRUDActions = ({ activity, act let operatedOnCompostPile: string | undefined; if ('isOperatedOn' in formData) { - operatedOnCompostPile = (formData as any).isOperatedOn["@id"]; + if (formData.isOperatedOn) + operatedOnCompostPile = (formData as any).isOperatedOn["@id"]; } if (operatedOnCompostPile) { const idParts = operatedOnCompostPile.split(':'); @@ -108,7 +113,8 @@ const ActivityDynamicCRUDActions = ({ activity, act let pesticideID: string | undefined; if ('usesPesticide' in formData) { - pesticideID = (formData as any).usesPesticide["@id"]; + if (formData.usesPesticide) + pesticideID = (formData as any).usesPesticide["@id"]; } if (pesticideID) { const idParts = pesticideID.split(':'); @@ -117,7 +123,8 @@ const ActivityDynamicCRUDActions = ({ activity, act let fertilizerID: string | undefined; if ('usesFertilizer' in formData) { - fertilizerID = (formData as any).usesFertilizer["@id"]; + if (formData.usesFertilizer) + fertilizerID = (formData as any).usesFertilizer["@id"]; } if (fertilizerID) { const idParts = fertilizerID.split(':'); @@ -126,7 +133,8 @@ const ActivityDynamicCRUDActions = ({ activity, act let severity: string | undefined; if ('severity' in formData) { - severity = (formData as any).severity; + if (formData.severity) + severity = (formData as any).severity; } if (severity) { setSeverity(severity); @@ -134,7 +142,8 @@ const ActivityDynamicCRUDActions = ({ activity, act let usesIrrigationSystem: string | undefined; if ('usesIrrigationSystem' in formData) { - usesIrrigationSystem = (formData as any).usesIrrigationSystem; + if (formData.usesIrrigationSystem) + usesIrrigationSystem = (formData as any).usesIrrigationSystem; } if (usesIrrigationSystem) { setUsesIrrigationSystem(usesIrrigationSystem); @@ -683,6 +692,9 @@ const ActivityDynamicCRUDActions = ({ activity, act (body.usesPesticide as { '@id': string })['@id'] = `urn:farmcalendar:Pesticide:${selectedPesticide}`; } if ('usesFertilizer' in body) { + if (!body.usesFertilizer) { + body.usesFertilizer = { '@id': '', '@type': 'Fertilizer' }; + } (body.usesFertilizer as { '@id': string })['@id'] = `urn:farmcalendar:Fertilizer:${selectedFertilizer}`; } if ('severity' in body) { From 1fba71dd9617af83745a0effcac93578ebe34fe6 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 17 Apr 2026 11:27:46 +0200 Subject: [PATCH 54/91] generic select canEdit field --- src/components/shared/GenericSelect/GenericSelect.tsx | 2 ++ src/components/shared/GenericSelect/GenericSelect.types.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/components/shared/GenericSelect/GenericSelect.tsx b/src/components/shared/GenericSelect/GenericSelect.tsx index 551f962..7c268f5 100644 --- a/src/components/shared/GenericSelect/GenericSelect.tsx +++ b/src/components/shared/GenericSelect/GenericSelect.tsx @@ -12,6 +12,7 @@ const GenericSelect = ({ selectedValue, setSelectedValue, transformResponse, + canEdit = true, data = undefined, multiple = false, }: GenericSelectProps) => { @@ -56,6 +57,7 @@ const GenericSelect = ({ {label} handleCropChange(e.target.value)} + > + + Please select a crop + + {crops.map(c => ( + {c.name} + ))} + + + + + + + + }> + Bio parameters + + + + {BIO_PARAM_FIELDS.map(({ key, label }) => { + const value = formData.bio_params[key]; + return ( + handleBioParamChange(key, e.target.value)} + /> + ); + })} + + + + + Fuzzy rules + {formData.fuzzy_rules.map((rule, idx) => { + const shouldShowRemoveButton = formData.fuzzy_rules.length > 1; + const humError = (rule.hum_lo ?? 0) < 0 || (rule.hum_hi ?? 100) > 100 || (rule.hum_lo ?? 0) > (rule.hum_hi ?? 100); + const tempError = (rule.temp_lo ?? -999) > (rule.temp_hi ?? 999); + const rainError = (rule.rain_min ?? 0) < 0; + return ( + + + + + #{idx + 1} + + Risk level + + + handleRuleTypeChange(idx, e.target.value)} + /> + {shouldShowRemoveButton && ( + handleRemoveRule(idx)}> + + + )} + + + handleRuleNumberChange(idx, 'hum_lo', e.target.value)} + error={humError} + inputProps={{ min: 0, max: 100 }} + /> + handleRuleNumberChange(idx, 'hum_hi', e.target.value)} + error={humError} + inputProps={{ min: 0, max: 100 }} + /> + handleRuleNumberChange(idx, 'temp_lo', e.target.value)} + error={tempError} + /> + handleRuleNumberChange(idx, 'temp_hi', e.target.value)} + error={tempError} + /> + handleRuleNumberChange(idx, 'rain_min', e.target.value)} + error={rainError} + inputProps={{ min: 0 }} + /> + + + + + ); + })} + {canEdit && ( + + + + )} + + + + {threatModel && ( + + + + + )} + {!threatModel && ( + + + + )} + + + + + ); +}; + +export default ThreatModelCRUDActions; diff --git a/src/components/dashboard/services/ThreatModelCRUDActions/ThreatModelCRUDActions.types.ts b/src/components/dashboard/services/ThreatModelCRUDActions/ThreatModelCRUDActions.types.ts new file mode 100644 index 0000000..624a3c7 --- /dev/null +++ b/src/components/dashboard/services/ThreatModelCRUDActions/ThreatModelCRUDActions.types.ts @@ -0,0 +1,10 @@ +import { Crop } from "@models/Crop"; +import { ThreatModel } from "@models/ThreatModel"; + +export interface ThreatModelCRUDActionsProps { + threatModel?: ThreatModel; + crops: Crop[]; + onAction?: () => void; + canEdit: boolean; + canDelete: boolean; +} diff --git a/src/main.tsx b/src/main.tsx index 71d073d..bb48745 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -31,7 +31,7 @@ import SoilMoistureAnalysisPage from '@pages/dashboard/services/Irrigation/SoilM import IrrigationOperationsReportPage from '@pages/dashboard/services/FarmCalendar/ReportingService/IrrigationOperations.tsx'; import FarmAnimalsReportPage from '@pages/dashboard/services/FarmCalendar/ReportingService/FarmAnimals.tsx'; import PestsPage from '@pages/dashboard/services/PestAndDisease/Pests.tsx'; -import DiseasesPage from '@pages/dashboard/services/PestAndDisease/Diseases.tsx'; +import ThreatModelsPage from '@pages/dashboard/services/PestAndDisease/ThreatModels.tsx'; import RiskForecastPage from '@pages/dashboard/services/PestAndDisease/RiskForecast.tsx'; import FarmCalendarLayout from './layouts/services/FarmCalendarLayout.tsx'; @@ -151,8 +151,8 @@ const router = createBrowserRouter([ Component: GrowingDegreeDaysPage }, { - path: 'diseases', - Component: DiseasesPage + path: 'threat-models', + Component: ThreatModelsPage }, { path: 'risk-forecast', diff --git a/src/models/Crop.ts b/src/models/Crop.ts new file mode 100644 index 0000000..d93c53d --- /dev/null +++ b/src/models/Crop.ts @@ -0,0 +1,5 @@ +export interface Crop { + id: string; + name: string; + description: string | null; +} diff --git a/src/models/ThreatModel.ts b/src/models/ThreatModel.ts index 6f9e2c5..128b4e0 100644 --- a/src/models/ThreatModel.ts +++ b/src/models/ThreatModel.ts @@ -1,20 +1,36 @@ -export interface ThreatModelBioParams { - t_base?: number; - pheno_hi?: number; - pheno_lo?: number; - min_streak?: number; - t_lethal_max?: number; - t_lethal_min?: number; - pheno_frac_hi?: number; - pheno_frac_lo?: number; - t_optimal_max?: number; - t_optimal_min?: number; - pheno_frac_ref_gdd5?: number; +export type RiskLevel = 'low' | 'moderate' | 'high' | 'critical'; + +export const RISK_LEVELS: RiskLevel[] = ['low', 'moderate', 'high', 'critical']; + +export interface BioParams { + t_base?: number | null; + t_lethal_min?: number | null; + t_lethal_max?: number | null; + t_optimal_min?: number | null; + t_optimal_max?: number | null; + min_streak?: number | null; + pheno_frac_lo?: number | null; + pheno_frac_hi?: number | null; + pheno_frac_ref_gdd5?: number | null; + pheno_lo?: number | null; + pheno_hi?: number | null; + min_wetness_hours_critical?: number | null; + min_wetness_hours_high?: number | null; +} + +export interface FuzzyRule { + hum_lo?: number; + hum_hi?: number; + temp_lo?: number; + temp_hi?: number; + rain_min?: number; + risk_level: RiskLevel; + type?: string | null; } export interface ThreatModelDefinition { - bio_params?: ThreatModelBioParams; - fuzzy_rules?: unknown[]; + bio_params: BioParams; + fuzzy_rules: FuzzyRule[]; } export interface ThreatModel { @@ -26,3 +42,20 @@ export interface ThreatModel { definition: ThreatModelDefinition; crop_id: string; } + +export interface ThreatModelCreate { + scientific_name: string; + common_name: string; + label?: string | null; + note?: string | null; + definition: ThreatModelDefinition; + crop_id: string; +} + +export interface ThreatModelUpdate { + scientific_name?: string | null; + common_name?: string | null; + label?: string | null; + note?: string | null; + definition?: ThreatModelDefinition | null; +} diff --git a/src/pages/dashboard/services/PestAndDisease/Diseases.tsx b/src/pages/dashboard/services/PestAndDisease/Diseases.tsx deleted file mode 100644 index 86ae6f0..0000000 --- a/src/pages/dashboard/services/PestAndDisease/Diseases.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import GenericSortableTable from "@components/shared/GenericSortableTable/GenericSortableTable"; -import { HeadCell } from "@components/shared/GenericSortableTable/GenericSortableTable.types"; -import useFetch from "@hooks/useFetch"; -import { Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Skeleton, Typography } from "@mui/material"; -import { ChangeEvent, useEffect, useState } from "react"; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { DiseasesResponseModel } from "@models/Disease"; - -import FileUploadIcon from '@mui/icons-material/FileUpload'; -import PublishIcon from '@mui/icons-material/Publish'; -import useSnackbar from "@hooks/useSnackbar"; -import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; -import { ServiceContextType } from "@layouts/services/PestAndDiseaseLayout"; -import { useOutletContext } from "react-router-dom"; - -const DiseasesPage = () => { - const { actions } = useOutletContext(); - const canAdd = actions.includes('add'); - // const canEdit = actions.includes('edit'); - // const canDelete = actions.includes('delete'); - - const [diseases, setDiseases] = useState([]); - - const [selectedFile, setSelectedFile] = useState(null); - - const [expanded, setExpanded] = useState(false); - - const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); - - const { fetchData: getFetchData, response: getResponse, error: getError, loading: getLoading } = useFetch( - `proxy/pdm/api/v1/pest-model/`, - { - method: 'GET', - } - ); - - const { fetchData: uploadFetchData, response: uploadResponse, error: uploadError, loading: uploadLoading } = useFetch( - `proxy/pdm/api/v1/pest-model/upload-excel/`, - { - method: 'POST', - } - ); - - useEffect(() => { - getFetchData(); - }, []); - - useEffect(() => { - if (getResponse) { - const formattedDiseases = getResponse.pests.map((d) => { - return { - id: d.id, - name: d.name, - description: d.description, - geoAreasOfApplication: d.geo_areas_of_application - } - }) - setDiseases(formattedDiseases); - } - }, [getResponse]) - - useEffect(() => { - if (uploadResponse) { - getFetchData(); - showSnackbar('success', "Disease dataset added successfully!"); - setExpanded(false); - } - }, [uploadResponse]) - - useEffect(() => { - if (uploadError) { - showSnackbar('error', uploadError?.message.toString() ?? 'Error uploading file'); - } - }, [uploadError]) - - interface DiseaseRow { - id: string; - name: string; - description: string; - geoAreasOfApplication: string; - } - - const diseasesHeadCells: readonly HeadCell[] = [ - { id: 'name', numeric: false, label: 'Name' }, - { id: 'description', numeric: false, label: 'Description' }, - { id: 'geoAreasOfApplication', numeric: false, label: 'GEO areas of application' }, - ]; - - const handleAccordionChange = () => { - setExpanded(!expanded); - }; - - const handleFileChange = (event: ChangeEvent) => { - if (event.target.files && event.target.files.length > 0) { - console.log(event.target.files[0]); - - setSelectedFile(event.target.files[0]); - } - }; - - const handleSubmit = async () => { - if (!selectedFile) return; - const formData = new FormData(); - formData.append('excel_file', selectedFile); - await uploadFetchData({ body: formData }); - } - - return ( - <> - - - }> - Add new disease - - - - - Upload an excel file containing a dataset - - - The file needs to have .xlsx as its extension. - - - { - selectedFile ? - - File uploaded, please submit. - : - - Please upload a file to the platform. - - } - - - - - - - - - - - {getLoading && } - { - !getLoading && !getError && - - } - - - - ) -} - -export default DiseasesPage; \ No newline at end of file diff --git a/src/pages/dashboard/services/PestAndDisease/ThreatModels.tsx b/src/pages/dashboard/services/PestAndDisease/ThreatModels.tsx new file mode 100644 index 0000000..86d859a --- /dev/null +++ b/src/pages/dashboard/services/PestAndDisease/ThreatModels.tsx @@ -0,0 +1,234 @@ +import GenericSortableTable from "@components/shared/GenericSortableTable/GenericSortableTable"; +import { HeadCell } from "@components/shared/GenericSortableTable/GenericSortableTable.types"; +import useFetch from "@hooks/useFetch"; +import { Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Skeleton, Typography } from "@mui/material"; +import { ChangeEvent, useEffect, useMemo, useState } from "react"; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { ThreatModel } from "@models/ThreatModel"; +import { Crop } from "@models/Crop"; +import FileUploadIcon from '@mui/icons-material/FileUpload'; +import PublishIcon from '@mui/icons-material/Publish'; +import useSnackbar from "@hooks/useSnackbar"; +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import { ServiceContextType } from "@layouts/services/PestAndDiseaseLayout"; +import { useOutletContext } from "react-router-dom"; +import useDialog from "@hooks/useDialog"; +import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; +import ThreatModelCRUDActions from "@components/dashboard/services/ThreatModelCRUDActions/ThreatModelCRUDActions"; + +interface ThreatModelRow { + id: string; + commonName: string; + scientificName: string; + crop: string; + label: string; +} + +const ThreatModelsPage = () => { + const { actions } = useOutletContext(); + const canAdd = actions.includes('add'); + const canEdit = actions.includes('edit'); + const canDelete = actions.includes('delete'); + + const [threatModels, setThreatModels] = useState([]); + const [crops, setCrops] = useState([]); + const [selectedThreatModel, setSelectedThreatModel] = useState(undefined); + + const [selectedFile, setSelectedFile] = useState(null); + const [expanded, setExpanded] = useState(false); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const { dialogProps, showDialog } = useDialog(); + + const { fetchData: getFetchData, response: getResponse, error: getError, loading: getLoading } = useFetch( + `proxy/pdm/api/v1/threat-model/`, + { method: 'GET' } + ); + + const { fetchData: getCropsFetch, response: cropsResponse } = useFetch( + `proxy/pdm/api/v1/crop/`, + { method: 'GET' } + ); + + const { fetchData: uploadFetchData, response: uploadResponse, error: uploadError, loading: uploadLoading } = useFetch( + `proxy/pdm/api/v1/threat-model/import-excel/`, + { method: 'POST' } + ); + + useEffect(() => { + getFetchData(); + getCropsFetch(); + }, []); + + useEffect(() => { + if (Array.isArray(getResponse)) setThreatModels(getResponse); + }, [getResponse]); + + useEffect(() => { + if (Array.isArray(cropsResponse)) setCrops(cropsResponse); + }, [cropsResponse]); + + useEffect(() => { + if (uploadResponse) { + getFetchData(); + showSnackbar('success', 'Threat models imported successfully!'); + setExpanded(false); + setSelectedFile(null); + } + }, [uploadResponse]); + + useEffect(() => { + if (uploadError) { + showSnackbar('error', uploadError?.message?.toString() ?? 'Error uploading file'); + } + }, [uploadError]); + + const cropNameById = useMemo(() => { + const map = new Map(); + crops.forEach(c => map.set(c.id, c.name)); + return map; + }, [crops]); + + const rows: ThreatModelRow[] = useMemo(() => threatModels.map(tm => ({ + id: tm.id, + commonName: tm.common_name, + scientificName: tm.scientific_name, + crop: cropNameById.get(tm.crop_id) ?? 'โ€”', + label: tm.label ?? '', + })), [threatModels, cropNameById]); + + const headCells: readonly HeadCell[] = [ + { id: 'commonName', numeric: false, label: 'Common name' }, + { id: 'scientificName', numeric: false, label: 'Scientific name' }, + { id: 'crop', numeric: false, label: 'Crop' }, + { id: 'label', numeric: false, label: 'Label' }, + ]; + + const handleRowClick = (row: ThreatModelRow) => { + const tm = threatModels.find(t => t.id === row.id); + if (!tm) return; + setSelectedThreatModel(tm); + showDialog({ + title: `Details for ${tm.common_name || tm.scientific_name}`, + variant: 'empty', + children: <>, + }); + }; + + const handleCloseDialog = () => { + dialogProps.onClose(); + setSelectedThreatModel(undefined); + }; + + const handleAccordionChange = () => setExpanded(prev => !prev); + + const handleFileChange = (event: ChangeEvent) => { + if (event.target.files && event.target.files.length > 0) { + setSelectedFile(event.target.files[0]); + } + }; + + const handleSubmit = async () => { + if (!selectedFile) return; + const formData = new FormData(); + formData.append('file', selectedFile); + await uploadFetchData({ body: formData }); + }; + + const onAfterEdit = () => { + getFetchData(); + handleCloseDialog(); + }; + + return ( + <> + + + }> + Add new threat model + + + + + Bulk import from Excel + + Upload an Excel file containing threat model definitions. + + + The file needs to have .xlsx as its extension. + + + {selectedFile ? 'File selected, please submit.' : 'Please select a file to import.'} + + + + + + + + + + + + Create a single threat model + { + getFetchData(); + setExpanded(false); + }} + canEdit={canAdd} + canDelete={canDelete} + /> + + + + + {getLoading && } + {!getLoading && !getError && ( + + )} + + + + + + + ); +}; + +export default ThreatModelsPage; From b005a99451c56f1027139e4f80c46ad7f7c33a3f Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Thu, 14 May 2026 23:36:41 +0200 Subject: [PATCH 67/91] implemented validation fixes and generic select additions --- .../ActivityDynamicCRUDActions.tsx | 184 ++++++++++++++++-- .../shared/GenericSelect/GenericSelect.tsx | 9 +- .../GenericSelect/GenericSelect.types.ts | 3 + 3 files changed, 176 insertions(+), 20 deletions(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index eeb8129..54a71e0 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -22,10 +22,71 @@ import { FarmCalendarActivityModel } from "@models/FarmCalendarActivity"; import useFetch from "@hooks/useFetch"; import { useNavigate } from "react-router-dom"; +const REQUIRED_KEYS_BY_TYPE: Record> = { + AddRawMaterialOperation: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'operatedOn', 'hasCompostMaterial', + ]), + CompostOperation: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'isOperatedOn', + ]), + CompostTurningOperation: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'operatedOn', + ]), + CropGrowthStageObservation: new Set([ + 'title', 'phenomenonTime', 'madeBySensor.name', 'hasAgriCrop', + 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', + ]), + CropProtectionOperation: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'operatedOn', + 'hasAppliedAmount.numericValue', 'hasAppliedAmount.unit', 'usesPesticide', + ]), + CropStressIndicatorObservation: new Set([ + 'title', 'phenomenonTime', 'madeBySensor.name', 'hasAgriCrop', + 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', + ]), + DiseaseDetectionObservation: new Set([ + 'title', 'phenomenonTime', 'madeBySensor.name', 'hasArea', + 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', + ]), + FertilizationOperation: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'operatedOn', + 'hasAppliedAmount.numericValue', 'hasAppliedAmount.unit', + 'hasApplicationMethod', 'usesFertilizer', + ]), + FarmCalendarActivity: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', + ]), + Alert: new Set([ + 'title', 'validFrom', 'validTo', 'relatedObservation', + ]), + Observation: new Set([ + 'title', 'phenomenonTime', 'madeBySensor.name', + 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', + ]), + IrrigationOperation: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'operatedOn', + 'hasAppliedAmount.numericValue', 'hasAppliedAmount.unit', 'usesIrrigationSystem', + ]), + SprayingRecommendationObservation: new Set([ + 'title', 'phenomenonTime', 'madeBySensor.name', 'hasArea', + 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', 'usesPesticide', + ]), + VigorEstimationObservation: new Set([ + 'title', 'phenomenonTime', 'madeBySensor.name', 'hasArea', + 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', + ]), + YieldPredictionObservation: new Set([ + 'title', 'phenomenonTime', 'madeBySensor.name', 'hasArea', + 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', + ]), +}; + const ActivityDynamicCRUDActions = ({ activity, activityTypes, onAdd, onDelete, onSave, loading, canEdit, canDelete }: ActivityDynamicCRUDActionsProps) => { const navigate = useNavigate(); const [formData, setFormData] = useState(activity); + const requiredKeys = REQUIRED_KEYS_BY_TYPE[formData['@type']] ?? new Set(); + const isReq = (key: string) => requiredKeys.has(key); const [selectedParcel, setSelectedParcel] = useState(''); const [selectedAgriCrop, setSelectedAgriCrop] = useState(''); const [selectedAgriMachines, setSelectedAgriMachines] = useState([]); @@ -262,6 +323,8 @@ const ActivityDynamicCRUDActions = ({ activity, act /** -------------------------------------------------------------------------- */ + const isDateInvalid = (v: any) => !(v && dayjs(v).isValid()); + /** Field rendering helpers start */ const renderDateFields = () => { return ( @@ -272,6 +335,12 @@ const ActivityDynamicCRUDActions = ({ activity, act label="Start datetime" value={dayjs(formData.hasStartDatetime as string | Date | null)} onChange={(val) => handleDateChange(val, 'hasStartDatetime')} + slotProps={{ + textField: { + required: isReq('hasStartDatetime'), + error: isReq('hasStartDatetime') && isDateInvalid(formData.hasStartDatetime), + }, + }} /> )} {'phenomenonTime' in formData && ( @@ -280,14 +349,26 @@ const ActivityDynamicCRUDActions = ({ activity, act label="Start datetime" value={dayjs(formData.phenomenonTime as string | Date | null)} onChange={(val) => handleDateChange(val, 'phenomenonTime')} + slotProps={{ + textField: { + required: isReq('phenomenonTime'), + error: isReq('phenomenonTime') && isDateInvalid(formData.phenomenonTime), + }, + }} /> )} {'validFrom' in formData && ( handleDateChange(val, 'validFrom')} + slotProps={{ + textField: { + required: isReq('validFrom'), + error: isReq('validFrom') && isDateInvalid(formData.validFrom), + }, + }} /> )} {'hasEndDatetime' in formData && ( @@ -301,9 +382,15 @@ const ActivityDynamicCRUDActions = ({ activity, act {'validTo' in formData && ( handleDateChange(val, 'validTo')} + slotProps={{ + textField: { + required: isReq('validTo'), + error: isReq('validTo') && isDateInvalid(formData.validTo), + }, + }} /> )} @@ -318,9 +405,10 @@ const ActivityDynamicCRUDActions = ({ activity, act slotProps={{ input: { readOnly: !canEdit } }} fullWidth margin="normal" label="Responsible agent" name="responsibleAgent" + required={isReq('responsibleAgent')} value={formData.responsibleAgent ?? ''} onChange={handleChange} - error={!(formData.responsibleAgent ? formData.responsibleAgent as string : '').trim()} /> + error={isReq('responsibleAgent') && !(formData.responsibleAgent ? formData.responsibleAgent as string : '').trim()} /> )} {'usesAgriculturalMachinery' in formData && ( @@ -349,9 +437,10 @@ const ActivityDynamicCRUDActions = ({ activity, act slotProps={{ input: { readOnly: !canEdit } }} fullWidth margin="normal" label="Made by sensor" name="madeBySensor.name" + required={isReq('madeBySensor.name')} value={(formData.madeBySensor as SensorShape).name ?? ''} onChange={handleChange} - error={!(formData.madeBySensor as SensorShape).name?.trim()} /> + error={isReq('madeBySensor.name') && !(formData.madeBySensor as SensorShape).name?.trim()} /> )} {'hasResult' in formData && ( @@ -359,16 +448,18 @@ const ActivityDynamicCRUDActions = ({ activity, act slotProps={{ input: { readOnly: !canEdit } }} fullWidth margin="normal" label="Value" name="hasResult.hasValue" + required={isReq('hasResult.hasValue')} value={(formData.hasResult as ResultShape).hasValue ?? ''} onChange={handleChange} - error={!(formData.hasResult as ResultShape).hasValue?.trim()} /> + error={isReq('hasResult.hasValue') && !(formData.hasResult as ResultShape).hasValue?.trim()} /> + error={isReq('hasResult.unit') && !(formData.hasResult as ResultShape).unit?.trim()} /> )} {'observedProperty' in formData && ( @@ -376,9 +467,10 @@ const ActivityDynamicCRUDActions = ({ activity, act slotProps={{ input: { readOnly: !canEdit } }} fullWidth margin="normal" label="Observed property" name="observedProperty" + required={isReq('observedProperty')} value={formData.observedProperty ?? ''} onChange={handleChange} - error={!(formData.observedProperty as string).trim()} /> + error={isReq('observedProperty') && !(formData.observedProperty as string).trim()} /> )} ) @@ -418,6 +510,8 @@ const ActivityDynamicCRUDActions = ({ activity, act transformResponse={a => a.actions.POST.usesIrrigationSystem.choices} getOptionLabel={item => item.display_name} getOptionValue={item => item.value} + required={isReq('usesIrrigationSystem')} + error={isReq('usesIrrigationSystem') && !usesIrrigationSystem} /> )} @@ -437,6 +531,8 @@ const ActivityDynamicCRUDActions = ({ activity, act setSelectedValue={setSelectedAgriCrop} getOptionLabel={item => `${item.name} - ${item.cropSpecies.name} - ${item.growth_stage}`} getOptionValue={item => item["@id"].split(':')[3]} + required={isReq('hasAgriCrop')} + error={isReq('hasAgriCrop') && !selectedAgriCrop} /> )} @@ -453,9 +549,10 @@ const ActivityDynamicCRUDActions = ({ activity, act label="Has area" name="hasArea" type="number" + required={isReq('hasArea')} value={isNaN(formData.hasArea as number) ? '' : formData.hasArea} onChange={handleChange} - error={isNaN(formData.hasArea as number)} /> + error={isReq('hasArea') && isNaN(formData.hasArea as number)} /> )} ) @@ -469,9 +566,10 @@ const ActivityDynamicCRUDActions = ({ activity, act slotProps={{ input: { readOnly: !canEdit } }} fullWidth margin="normal" label="Operated on compost pile" name="isOperatedOn.@id" + required={isReq('isOperatedOn')} value={operatedOnCompostPile} onChange={handleOperatedOnCompostPile} - error={!operatedOnCompostPile.trim()} /> + error={isReq('isOperatedOn') && !operatedOnCompostPile.trim()} /> )} ) @@ -485,9 +583,10 @@ const ActivityDynamicCRUDActions = ({ activity, act slotProps={{ input: { readOnly: !canEdit } }} fullWidth margin="normal" label="Application method" name="hasApplicationMethod" + required={isReq('hasApplicationMethod')} value={formData.hasApplicationMethod} onChange={handleChange} - error={!(formData.hasApplicationMethod as string).trim()} /> + error={isReq('hasApplicationMethod') && !(formData.hasApplicationMethod as string).trim()} /> )} ) @@ -505,17 +604,19 @@ const ActivityDynamicCRUDActions = ({ activity, act fullWidth label="Applied amount" name="hasAppliedAmount.numericValue" type="number" + required={isReq('hasAppliedAmount.numericValue')} value={isNaN((formData.hasAppliedAmount as AppliedAmountShape)['numericValue']) ? '' : (formData.hasAppliedAmount as AppliedAmountShape)["numericValue"]} onChange={handleChange} - error={isNaN((formData.hasAppliedAmount as AppliedAmountShape)['numericValue'])} /> + error={isReq('hasAppliedAmount.numericValue') && isNaN((formData.hasAppliedAmount as AppliedAmountShape)['numericValue'])} /> + error={isReq('hasAppliedAmount.unit') && !(formData.hasAppliedAmount as AppliedAmountShape).unit?.trim()} /> )} @@ -535,6 +636,8 @@ const ActivityDynamicCRUDActions = ({ activity, act setSelectedValue={setSelectedPesticide} getOptionLabel={item => `${item.hasCommercialName} - ${item.hasActiveSubstance} - ${item.hasPreharvestInterval}`} getOptionValue={item => item["@id"].split(':')[3]} + required={isReq('usesPesticide')} + error={isReq('usesPesticide') && !selectedPesticide} /> )} @@ -554,6 +657,8 @@ const ActivityDynamicCRUDActions = ({ activity, act setSelectedValue={setSelectedFertilizer} getOptionLabel={item => `${item.hasCommercialName} - ${item.hasActiveSubstance} - ${item.hasNutrientConcentration}`} getOptionValue={item => item["@id"].split(':')[3]} + required={isReq('usesFertilizer')} + error={isReq('usesFertilizer') && !selectedFertilizer} /> )} @@ -679,6 +784,46 @@ const ActivityDynamicCRUDActions = ({ activity, act /** -------------------------------------------------------------------------- */ + /** Validation start */ + const fieldValidators: Record boolean> = { + 'title': () => !!formData.title?.trim(), + 'responsibleAgent': () => !!((formData as any).responsibleAgent ?? '').toString().trim(), + 'hasStartDatetime': () => !isDateInvalid((formData as any).hasStartDatetime), + 'phenomenonTime': () => !isDateInvalid((formData as any).phenomenonTime), + 'validFrom': () => !isDateInvalid((formData as any).validFrom), + 'validTo': () => !isDateInvalid((formData as any).validTo), + 'operatedOn': () => !!selectedParcel, + 'hasAgriCrop': () => !!selectedAgriCrop, + 'usesPesticide': () => !!selectedPesticide, + 'usesFertilizer': () => !!selectedFertilizer, + 'usesIrrigationSystem': () => !!usesIrrigationSystem, + 'relatedObservation': () => !!parentActivity, + 'isOperatedOn': () => !!operatedOnCompostPile.trim(), + 'hasArea': () => !isNaN((formData as any).hasArea as number), + 'hasAppliedAmount.numericValue': () => !isNaN(((formData as any).hasAppliedAmount?.numericValue) as number), + 'hasAppliedAmount.unit': () => !!((formData as any).hasAppliedAmount?.unit ?? '').toString().trim(), + 'hasResult.hasValue': () => !!((formData as any).hasResult?.hasValue ?? '').toString().trim(), + 'hasResult.unit': () => !!((formData as any).hasResult?.unit ?? '').toString().trim(), + 'observedProperty': () => !!((formData as any).observedProperty ?? '').toString().trim(), + 'madeBySensor.name': () => !!((formData as any).madeBySensor?.name ?? '').toString().trim(), + 'hasApplicationMethod': () => !!((formData as any).hasApplicationMethod ?? '').toString().trim(), + 'hasCompostMaterial': () => { + const arr = (formData as any).hasCompostMaterial; + if (!Array.isArray(arr) || arr.length === 0) return false; + return arr.every((m: any) => + !!m.typeName?.trim() + && !isNaN(m.quantityValue?.numericValue) + && !!m.quantityValue?.unit?.trim() + ); + }, + }; + + const isFormInvalid = Array.from(requiredKeys).some(k => { + const fn = fieldValidators[k]; + return fn ? !fn() : false; + }); + /** Validation end */ + /** Button handler functions start */ const prepPostAndPatch = () => { let body = { ...formData }; @@ -762,8 +907,9 @@ const ActivityDynamicCRUDActions = ({ activity, act {renderDateFields()} ({ activity, act setSelectedValue={setSelectedParcel} getOptionLabel={item => `${item.identifier} (${item.category})`} getOptionValue={item => item["@id"].split(':')[3]} + required={isReq('operatedOn')} + error={isReq('operatedOn') && !selectedParcel} /> {/* NTH: string filtering of the displayed activities */} canEdit={canEdit} endpoint='' data={allActivities} - label='Part of activity' + label={'relatedObservation' in formData ? 'Related observation' : 'Part of activity'} // Filtering out the current activity to avoid recursive links transformResponse={resp => { return resp.filter(fa => { @@ -799,6 +947,8 @@ const ActivityDynamicCRUDActions = ({ activity, act item => `${item.title} (${dayjs(item.hasStartDatetime).format('YYYY-MM-DD HH:mm')} - ${dayjs(item.hasEndDatetime).format('YYYY-MM-DD HH:mm')})` } getOptionValue={item => item["@id"].split(':')[3]} + required={isReq('relatedObservation')} + error={isReq('relatedObservation') && !parentActivity} /> {renderSeverity()} {renderSensorResultAndObservedProperty()} @@ -822,8 +972,7 @@ const ActivityDynamicCRUDActions = ({ activity, act startIcon={} loading={loading} loadingPosition="start" - // disabled={isFormInvalid} - disabled={!canEdit} + disabled={!canEdit || isFormInvalid} onClick={handlePost} > Add @@ -834,8 +983,7 @@ const ActivityDynamicCRUDActions = ({ activity, act startIcon={} loading={loading} loadingPosition="start" - disabled={!canEdit} - // disabled={isFormInvalid} + disabled={!canEdit || isFormInvalid} onClick={handlePatch} > Save Changes diff --git a/src/components/shared/GenericSelect/GenericSelect.tsx b/src/components/shared/GenericSelect/GenericSelect.tsx index 7c268f5..5a3efcb 100644 --- a/src/components/shared/GenericSelect/GenericSelect.tsx +++ b/src/components/shared/GenericSelect/GenericSelect.tsx @@ -1,4 +1,4 @@ -import { Box, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, Chip } from '@mui/material'; +import { Box, FormControl, FormHelperText, InputLabel, MenuItem, Select, SelectChangeEvent, Chip } from '@mui/material'; import { useEffect, useMemo } from 'react'; import { GenericSelectProps } from './GenericSelect.types'; import useFetch from '@hooks/useFetch'; @@ -15,6 +15,9 @@ const GenericSelect = ({ canEdit = true, data = undefined, multiple = false, + error = false, + required = false, + helperText, }: GenericSelectProps) => { const { fetchData, response, loading } = useFetch(endpoint, { method: method }); @@ -54,7 +57,7 @@ const GenericSelect = ({ return ( - + {label} + {helperText && {helperText}} ); diff --git a/src/components/shared/GenericSelect/GenericSelect.types.ts b/src/components/shared/GenericSelect/GenericSelect.types.ts index 9843d65..21534d8 100644 --- a/src/components/shared/GenericSelect/GenericSelect.types.ts +++ b/src/components/shared/GenericSelect/GenericSelect.types.ts @@ -7,6 +7,9 @@ interface GenericSelectPropsBase { getOptionLabel: (item: T) => string; getOptionValue: (item: T) => string; transformResponse?: (response: R) => T[]; + error?: boolean; + required?: boolean; + helperText?: string; } // Props for a SINGLE select (multiple is false or undefined) From 9c2414e0cf01a5e65ac81ac405d393875627b907 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 15 May 2026 13:23:48 +0200 Subject: [PATCH 68/91] fixed missing required fields due to missmatching key names --- .../ActivityDynamicCRUDActions.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index 54a71e0..3c617df 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -44,7 +44,7 @@ const REQUIRED_KEYS_BY_TYPE: Record> = { 'title', 'phenomenonTime', 'madeBySensor.name', 'hasAgriCrop', 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', ]), - DiseaseDetectionObservation: new Set([ + DiseaseDetection: new Set([ 'title', 'phenomenonTime', 'madeBySensor.name', 'hasArea', 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', ]), @@ -67,15 +67,15 @@ const REQUIRED_KEYS_BY_TYPE: Record> = { 'title', 'responsibleAgent', 'hasStartDatetime', 'operatedOn', 'hasAppliedAmount.numericValue', 'hasAppliedAmount.unit', 'usesIrrigationSystem', ]), - SprayingRecommendationObservation: new Set([ + SprayingRecommendation: new Set([ 'title', 'phenomenonTime', 'madeBySensor.name', 'hasArea', 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', 'usesPesticide', ]), - VigorEstimationObservation: new Set([ + VigorEstimation: new Set([ 'title', 'phenomenonTime', 'madeBySensor.name', 'hasArea', 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', ]), - YieldPredictionObservation: new Set([ + YieldPrediction: new Set([ 'title', 'phenomenonTime', 'madeBySensor.name', 'hasArea', 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', ]), @@ -818,7 +818,7 @@ const ActivityDynamicCRUDActions = ({ activity, act }, }; - const isFormInvalid = Array.from(requiredKeys).some(k => { + const isFormInvalid = !selectedParcel || Array.from(requiredKeys).some(k => { const fn = fieldValidators[k]; return fn ? !fn() : false; }); @@ -926,8 +926,8 @@ const ActivityDynamicCRUDActions = ({ activity, act setSelectedValue={setSelectedParcel} getOptionLabel={item => `${item.identifier} (${item.category})`} getOptionValue={item => item["@id"].split(':')[3]} - required={isReq('operatedOn')} - error={isReq('operatedOn') && !selectedParcel} + required + error={!selectedParcel} /> {/* NTH: string filtering of the displayed activities */} From 535fe431597d43990fda398e1fde7b928ef1c6f3 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 15 May 2026 15:01:39 +0200 Subject: [PATCH 69/91] cherry picked generic select changes from farm-activity-crud-validation-fix --- src/components/shared/GenericSelect/GenericSelect.tsx | 9 +++++++-- .../shared/GenericSelect/GenericSelect.types.ts | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/shared/GenericSelect/GenericSelect.tsx b/src/components/shared/GenericSelect/GenericSelect.tsx index 7c268f5..5a3efcb 100644 --- a/src/components/shared/GenericSelect/GenericSelect.tsx +++ b/src/components/shared/GenericSelect/GenericSelect.tsx @@ -1,4 +1,4 @@ -import { Box, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, Chip } from '@mui/material'; +import { Box, FormControl, FormHelperText, InputLabel, MenuItem, Select, SelectChangeEvent, Chip } from '@mui/material'; import { useEffect, useMemo } from 'react'; import { GenericSelectProps } from './GenericSelect.types'; import useFetch from '@hooks/useFetch'; @@ -15,6 +15,9 @@ const GenericSelect = ({ canEdit = true, data = undefined, multiple = false, + error = false, + required = false, + helperText, }: GenericSelectProps) => { const { fetchData, response, loading } = useFetch(endpoint, { method: method }); @@ -54,7 +57,7 @@ const GenericSelect = ({ return ( - + {label} + {helperText && {helperText}} ); diff --git a/src/components/shared/GenericSelect/GenericSelect.types.ts b/src/components/shared/GenericSelect/GenericSelect.types.ts index 9843d65..21534d8 100644 --- a/src/components/shared/GenericSelect/GenericSelect.types.ts +++ b/src/components/shared/GenericSelect/GenericSelect.types.ts @@ -7,6 +7,9 @@ interface GenericSelectPropsBase { getOptionLabel: (item: T) => string; getOptionValue: (item: T) => string; transformResponse?: (response: R) => T[]; + error?: boolean; + required?: boolean; + helperText?: string; } // Props for a SINGLE select (multiple is false or undefined) From b55480175a37c408f9c238bcf6052a921870c147 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 15 May 2026 15:02:17 +0200 Subject: [PATCH 70/91] implemented managing and registering new activity types --- package-lock.json | 32 ++++ package.json | 1 + .../GenericSortableTable.tsx | 32 ++-- .../GenericSortableTable.types.ts | 2 + src/main.tsx | 15 ++ .../ActivityTypes/ActivityTypeForm.tsx | 135 +++++++++++++++ .../ActivityTypes/ActivityTypes.tsx | 158 ++++++++++++++++++ .../ActivityTypes/AddActivityType.tsx | 70 ++++++++ .../ActivityTypes/EditActivityType.tsx | 98 +++++++++++ .../services/FarmCalendar/FarmCalendar.tsx | 11 +- 10 files changed, 537 insertions(+), 17 deletions(-) create mode 100644 src/pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypeForm.tsx create mode 100644 src/pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypes.tsx create mode 100644 src/pages/dashboard/services/FarmCalendar/ActivityTypes/AddActivityType.tsx create mode 100644 src/pages/dashboard/services/FarmCalendar/ActivityTypes/EditActivityType.tsx diff --git a/package-lock.json b/package-lock.json index 72b69a3..f4f7504 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "dayjs-plugin-utc": "^0.1.2", "highcharts": "^12.4.0", "jwt-decode": "^4.0.0", + "mui-color-input": "^9.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.6.3", @@ -1625,6 +1626,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -7269,6 +7279,28 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mui-color-input": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/mui-color-input/-/mui-color-input-9.0.0.tgz", + "integrity": "sha512-DNuLS+LCbtgSvj0CmG0z7Ge6i+cVporDKe4cZnckJZFMdz3Mkxnnk1s89vvXp5J+EvrmYWdkI3qsXdkAOzJw0Q==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0" + }, + "peerDependencies": { + "@emotion/react": "^11.13.0", + "@emotion/styled": "^11.13.0", + "@mui/material": "^7.0.0 || ^9.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", diff --git a/package.json b/package.json index f9f8e0a..17e1c22 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dayjs-plugin-utc": "^0.1.2", "highcharts": "^12.4.0", "jwt-decode": "^4.0.0", + "mui-color-input": "^9.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.6.3", diff --git a/src/components/shared/GenericSortableTable/GenericSortableTable.tsx b/src/components/shared/GenericSortableTable/GenericSortableTable.tsx index 2a8c25f..118ed9b 100644 --- a/src/components/shared/GenericSortableTable/GenericSortableTable.tsx +++ b/src/components/shared/GenericSortableTable/GenericSortableTable.tsx @@ -48,18 +48,22 @@ function EnhancedTableHead(props: EnhancedTableHeadProps) { padding={'normal'} sortDirection={orderBy === headCell.id ? order : false} > - - {headCell.label} - {orderBy === headCell.id ? ( - - {order === 'desc' ? 'sorted descending' : 'sorted ascending'} - - ) : null} - + {headCell.disableSort ? ( + headCell.label + ) : ( + + {headCell.label} + {orderBy === headCell.id ? ( + + {order === 'desc' ? 'sorted descending' : 'sorted ascending'} + + ) : null} + + )} ))} @@ -133,8 +137,8 @@ function GenericSortableTable({ onClick={() => onRowClick && onRowClick(row)} > {headCells.map((cell, cellIndex) => { - const originalValue = row[cell.id] as React.ReactNode; - const cellValue = originalValue == null ? 'N/A' : originalValue; + const rendered = cell.renderCell ? cell.renderCell(row) : (row[cell.id] as React.ReactNode); + const cellValue = rendered == null ? 'N/A' : rendered; if (cellIndex === 0) { return ( { id: keyof T; label: string; numeric: boolean; + disableSort?: boolean; + renderCell?: (row: T) => React.ReactNode; } export interface EnhancedTableHeadProps { diff --git a/src/main.tsx b/src/main.tsx index 71d073d..08fdaf0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -24,6 +24,9 @@ import FarmPage from '@pages/dashboard/services/FarmCalendar/FarmLocations/Farm. import TokenRefreshPage from '@pages/auth/TokenRefresh/TokenRefresh.tsx'; import RegisterCalendarActivityPage from '@pages/dashboard/services/FarmCalendar/FarmCalendarActivities/RegisterActivity.tsx'; import EditCalendarActivityPage from '@pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx'; +import ActivityTypesPage from '@pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypes.tsx'; +import AddActivityTypePage from '@pages/dashboard/services/FarmCalendar/ActivityTypes/AddActivityType.tsx'; +import EditActivityTypePage from '@pages/dashboard/services/FarmCalendar/ActivityTypes/EditActivityType.tsx'; import WeatherDataPage from '@pages/dashboard/services/WeatherData/WeatherData.tsx'; import EToCalculatorPage from '@pages/dashboard/services/Irrigation/EToCalculator.tsx'; import UploadDatasetPage from '@pages/dashboard/services/Irrigation/UploadDataset.tsx'; @@ -68,6 +71,18 @@ const router = createBrowserRouter([ path: 'farm-calendar/edit-activity/:id', Component: EditCalendarActivityPage }, + { + path: 'farm-calendar/activity-types', + Component: ActivityTypesPage + }, + { + path: 'farm-calendar/activity-types/add', + Component: AddActivityTypePage + }, + { + path: 'farm-calendar/activity-types/edit/:id', + Component: EditActivityTypePage + }, { path: 'farm-locations', children: [ diff --git a/src/pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypeForm.tsx b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypeForm.tsx new file mode 100644 index 0000000..e85463a --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypeForm.tsx @@ -0,0 +1,135 @@ +import { Box, Button, Card, CardContent, Stack, TextField } from "@mui/material"; +import { MuiColorInput } from "mui-color-input"; +import { FarmCalendarActivityTypeModel } from "@models/FarmCalendarActivityType"; +import SaveIcon from '@mui/icons-material/Save'; +import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; + +export type ActivityTypeFormValues = Pick< + FarmCalendarActivityTypeModel, + 'name' | 'description' | 'category' | 'background_color' | 'border_color' | 'text_color' +>; + +interface CategoryChoice { + value: string; + display_name: string; +} + +interface CategoryOptionsResponse { + actions?: { + POST?: { + category?: { + choices?: CategoryChoice[]; + }; + }; + }; +} + +interface ActivityTypeFormProps { + values: ActivityTypeFormValues; + setValues: React.Dispatch>; + onSubmit: () => void; + loading: boolean; + canEdit: boolean; + submitLabel: string; +} + +const ActivityTypeForm: React.FC = ({ values, setValues, onSubmit, loading, canEdit, submitLabel }) => { + const handleText = (key: keyof ActivityTypeFormValues) => (e: React.ChangeEvent) => { + setValues(prev => ({ ...prev, [key]: e.target.value })); + }; + + const handleColor = (key: keyof ActivityTypeFormValues) => (value: string) => { + setValues(prev => ({ ...prev, [key]: value })); + }; + + const setCategory: React.Dispatch> = (val) => { + setValues(prev => ({ + ...prev, + category: typeof val === 'function' ? (val as (p: string) => string)(prev.category) : val, + })); + }; + + const isInvalid = !values.name.trim() || !values.category.trim(); + + return ( + <> + + + + + + + canEdit={canEdit} + endpoint='proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/?format=json' + method="OPTIONS" + label='Category' + selectedValue={values.category} + setSelectedValue={setCategory} + transformResponse={r => r.actions?.POST?.category?.choices ?? []} + getOptionLabel={item => item.display_name} + getOptionValue={item => item.value} + required + error={!values.category.trim()} + /> + + + + + + + + + + + + + ); +}; + +export default ActivityTypeForm; diff --git a/src/pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypes.tsx b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypes.tsx new file mode 100644 index 0000000..6bc83ae --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypes.tsx @@ -0,0 +1,158 @@ +import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import GenericSortableTable from "@components/shared/GenericSortableTable/GenericSortableTable"; +import { HeadCell } from "@components/shared/GenericSortableTable/GenericSortableTable.types"; +import useDialog from "@hooks/useDialog"; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import { ServiceContextType } from "@layouts/services/FarmCalendarLayout"; +import { FarmCalendarActivityTypeModel } from "@models/FarmCalendarActivityType"; +import { Box, Button, IconButton, Stack, Typography } from "@mui/material"; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useOutletContext } from "react-router-dom"; + +interface ActivityTypeRow { + id: string; + name: string; + operations: null; +} + +const ActivityTypesPage = () => { + const navigate = useNavigate(); + const { actions } = useOutletContext(); + const canAdd = actions.includes('add'); + const canEdit = actions.includes('edit'); + const canDelete = actions.includes('delete'); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const { dialogProps, showDialog } = useDialog(); + + const [pendingDeleteId, setPendingDeleteId] = useState(null); + + const { fetchData: fetchList, response: listResponse, error: listError } = useFetch( + `proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/?format=json`, + { method: 'GET' } + ); + + const { fetchData: doDelete, response: deleteResponse, error: deleteError } = useFetch( + pendingDeleteId ? `proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/${pendingDeleteId}/` : '', + { method: 'DELETE' } + ); + + useEffect(() => { + fetchList(); + }, []); + + useEffect(() => { + if (listError) showSnackbar('error', 'Error loading activity types'); + }, [listError]); + + useEffect(() => { + if (deleteResponse) { + showSnackbar('success', 'Activity type deleted'); + setPendingDeleteId(null); + fetchList(); + } + }, [deleteResponse]); + + useEffect(() => { + if (deleteError) { + showSnackbar('error', 'Error deleting activity type'); + setPendingDeleteId(null); + } + }, [deleteError]); + + const rows: ActivityTypeRow[] = useMemo(() => { + if (!Array.isArray(listResponse)) return []; + return listResponse.map(t => ({ + id: t["@id"].split(':').pop() ?? t["@id"], + name: t.name, + operations: null, + })); + }, [listResponse]); + + const handleEdit = (id: string) => { + navigate(`edit/${id}`); + }; + + const handleDelete = (id: string) => { + setPendingDeleteId(id); + showDialog({ + title: 'Are you sure you want to delete this activity type?', + variant: 'yes-no', + children: <>, + }); + }; + + const confirmDelete = () => { + if (pendingDeleteId) { + doDelete(); + } + }; + + const headCells: HeadCell[] = [ + { id: 'name', label: 'Name', numeric: false }, + { + id: 'operations', + label: 'Operations', + numeric: false, + disableSort: true, + renderCell: (row) => ( + + { e.stopPropagation(); handleEdit(row.id); }} + aria-label="edit" + > + + + { e.stopPropagation(); handleDelete(row.id); }} + aria-label="delete" + > + + + + ), + }, + ]; + + return ( + <> + + Manage calendar activity types + + + + + data={rows} + headCells={headCells} + /> + + { dialogProps.onClose(); setPendingDeleteId(null); }} + onYes={confirmDelete} + /> + + + ); +}; + +export default ActivityTypesPage; diff --git a/src/pages/dashboard/services/FarmCalendar/ActivityTypes/AddActivityType.tsx b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/AddActivityType.tsx new file mode 100644 index 0000000..d449f2b --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/AddActivityType.tsx @@ -0,0 +1,70 @@ +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import { ServiceContextType } from "@layouts/services/FarmCalendarLayout"; +import { Box, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useNavigate, useOutletContext } from "react-router-dom"; +import ActivityTypeForm, { ActivityTypeFormValues } from "./ActivityTypeForm"; + +const emptyValues: ActivityTypeFormValues = { + name: '', + description: '', + category: '', + background_color: '#1976d2', + border_color: '#1976d2', + text_color: '#ffffff', +}; + +const AddActivityTypePage = () => { + const navigate = useNavigate(); + const { actions } = useOutletContext(); + const canEdit = actions.includes('add'); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const [values, setValues] = useState(emptyValues); + + const { fetchData, response, error, loading } = useFetch( + `proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/`, + { method: 'POST' } + ); + + const handleSubmit = () => { + fetchData({ body: values }); + }; + + useEffect(() => { + if (response) { + showSnackbar('success', 'Activity type created'); + navigate('/farm-calendar/activity-types'); + } + }, [response]); + + useEffect(() => { + if (error) showSnackbar('error', 'Error creating activity type'); + }, [error]); + + return ( + <> + + Add calendar activity type + + + + + ); +}; + +export default AddActivityTypePage; diff --git a/src/pages/dashboard/services/FarmCalendar/ActivityTypes/EditActivityType.tsx b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/EditActivityType.tsx new file mode 100644 index 0000000..459a3ae --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/EditActivityType.tsx @@ -0,0 +1,98 @@ +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import { ServiceContextType } from "@layouts/services/FarmCalendarLayout"; +import { FarmCalendarActivityTypeModel } from "@models/FarmCalendarActivityType"; +import { Box, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useNavigate, useOutletContext, useParams } from "react-router-dom"; +import ActivityTypeForm, { ActivityTypeFormValues } from "./ActivityTypeForm"; + +const emptyValues: ActivityTypeFormValues = { + name: '', + description: '', + category: '', + background_color: '#1976d2', + border_color: '#1976d2', + text_color: '#ffffff', +}; + +const EditActivityTypePage = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { actions } = useOutletContext(); + const canEdit = actions.includes('edit'); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const [values, setValues] = useState(emptyValues); + + const { fetchData: fetchOne, response: getResponse, error: getError } = useFetch( + `proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/${id}/?format=json`, + { method: 'GET' } + ); + + const { fetchData: doPatch, response: patchResponse, error: patchError, loading } = useFetch( + `proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/${id}/`, + { method: 'PATCH' } + ); + + useEffect(() => { + if (id) fetchOne(); + }, [id]); + + useEffect(() => { + if (getResponse) { + setValues({ + name: getResponse.name ?? '', + description: getResponse.description ?? '', + category: getResponse.category ?? '', + background_color: getResponse.background_color ?? '#1976d2', + border_color: getResponse.border_color ?? '#1976d2', + text_color: getResponse.text_color ?? '#ffffff', + }); + } + }, [getResponse]); + + useEffect(() => { + if (getError) showSnackbar('error', 'Error loading activity type'); + }, [getError]); + + const handleSubmit = () => { + doPatch({ body: values }); + }; + + useEffect(() => { + if (patchResponse) { + showSnackbar('success', 'Activity type updated'); + navigate('/farm-calendar/activity-types'); + } + }, [patchResponse]); + + useEffect(() => { + if (patchError) showSnackbar('error', 'Error updating activity type'); + }, [patchError]); + + return ( + <> + + Edit calendar activity type + + + + + ); +}; + +export default EditActivityTypePage; diff --git a/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx b/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx index e2a7a2b..8b66b53 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx @@ -1,7 +1,7 @@ import ParcelSelectionModule from "@components/dashboard/ParcelSelectionModule/ParcelSelectionModule"; import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; import useSnackbar from "@hooks/useSnackbar"; -import { Box, Button } from "@mui/material"; +import { Button, Stack } from "@mui/material"; import { useEffect, useMemo, useState } from "react"; import { EventInput } from '@fullcalendar/core'; import useFetch from "@hooks/useFetch"; @@ -91,14 +91,19 @@ const FarmCalendarPage = () => { <> - + - + + Date: Fri, 15 May 2026 15:27:11 +0200 Subject: [PATCH 71/91] fixes to severity and related observations --- .../ActivityDynamicCRUDActions.tsx | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index 3c617df..8576800 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -57,7 +57,7 @@ const REQUIRED_KEYS_BY_TYPE: Record> = { 'title', 'responsibleAgent', 'hasStartDatetime', ]), Alert: new Set([ - 'title', 'validFrom', 'validTo', 'relatedObservation', + 'title', 'validFrom', 'validTo', 'relatedObservation', 'severity', ]), Observation: new Set([ 'title', 'phenomenonTime', 'madeBySensor.name', @@ -99,6 +99,7 @@ const ActivityDynamicCRUDActions = ({ activity, act /** All calendar activities section start */ const [allActivities, setAllActivities] = useState([]); + const [allObservations, setAllObservations] = useState([]); const { fetchData: fetchDataAllActivities, response: responseAllActivities } = useFetch( `proxy/farmcalendar/api/v1/FarmCalendarActivities/?format=json`, @@ -107,15 +108,31 @@ const ActivityDynamicCRUDActions = ({ activity, act } ); + const { fetchData: fetchDataAllObservations, response: responseAllObservations } = useFetch( + `proxy/farmcalendar/api/v1/Observations/?format=json`, + { + method: 'GET', + } + ); + useEffect(() => { if (responseAllActivities) { setAllActivities(responseAllActivities); } }, [responseAllActivities]) + + useEffect(() => { + if (responseAllObservations) { + setAllObservations(responseAllObservations); + } + }, [responseAllObservations]) /** All calendar activities section end */ useEffect(() => { fetchDataAllActivities(); + if ('relatedObservation' in formData) { + fetchDataAllObservations(); + } let parcelID: string | undefined; if ('hasAgriParcel' in formData) { @@ -490,6 +507,8 @@ const ActivityDynamicCRUDActions = ({ activity, act transformResponse={a => a.actions.POST.severity.choices} getOptionLabel={item => item.display_name} getOptionValue={item => item.value} + required={isReq('severity')} + error={isReq('severity') && !severity} /> )} @@ -807,6 +826,7 @@ const ActivityDynamicCRUDActions = ({ activity, act 'observedProperty': () => !!((formData as any).observedProperty ?? '').toString().trim(), 'madeBySensor.name': () => !!((formData as any).madeBySensor?.name ?? '').toString().trim(), 'hasApplicationMethod': () => !!((formData as any).hasApplicationMethod ?? '').toString().trim(), + 'severity': () => !!severity, 'hasCompostMaterial': () => { const arr = (formData as any).hasCompostMaterial; if (!Array.isArray(arr) || arr.length === 0) return false; @@ -933,7 +953,7 @@ const ActivityDynamicCRUDActions = ({ activity, act canEdit={canEdit} endpoint='' - data={allActivities} + data={'relatedObservation' in formData ? allObservations : allActivities} label={'relatedObservation' in formData ? 'Related observation' : 'Part of activity'} // Filtering out the current activity to avoid recursive links transformResponse={resp => { @@ -943,9 +963,11 @@ const ActivityDynamicCRUDActions = ({ activity, act }} selectedValue={parentActivity} setSelectedValue={setParentActivity} - getOptionLabel={ - item => `${item.title} (${dayjs(item.hasStartDatetime).format('YYYY-MM-DD HH:mm')} - ${dayjs(item.hasEndDatetime).format('YYYY-MM-DD HH:mm')})` - } + getOptionLabel={item => { + const start = (item as any).hasStartDatetime ?? (item as any).phenomenonTime; + const end = (item as any).hasEndDatetime; + return `${item.title} (${dayjs(start).format('YYYY-MM-DD HH:mm')}${end ? ` - ${dayjs(end).format('YYYY-MM-DD HH:mm')}` : ''})`; + }} getOptionValue={item => item["@id"].split(':')[3]} required={isReq('relatedObservation')} error={isReq('relatedObservation') && !parentActivity} From e0bf87b381954f3f85bb6b63f2ca513077dcfd8b Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 19 May 2026 13:32:19 +0200 Subject: [PATCH 72/91] added navigateFallbackDenylist param to vite config, fix for invalid file download on certain browsers --- vite.config.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index 5ed36ec..bebea7e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,12 +27,17 @@ export default defineConfig({ clientsClaim: true, // also make sure it's not pre-cached globIgnores: ['**/env-config.js'], + navigateFallbackDenylist: [/^\/examples\//], // NEVER cache the runtime env file runtimeCaching: [ { urlPattern: /\/env-config\.js$/, handler: 'NetworkOnly', // or 'NetworkFirst' with low cache }, + { + urlPattern: /^\/examples\//, + handler: 'NetworkOnly', + }, ], }, @@ -43,5 +48,17 @@ export default defineConfig({ suppressWarnings: true, type: 'module', }, - })], + }), + // { + // name: 'inject-env-config', + // transformIndexHtml(html, ctx) { + // if (ctx.bundle) { + // return html.replace( + // '', + // ' \n' + // ) + // } + // }, + // } + ], }) \ No newline at end of file From 1cd535b704401381243ccfcf12ad6023bcc17c1c Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 19 May 2026 16:56:07 +0200 Subject: [PATCH 73/91] removed sign up page and all references to it --- src/main.tsx | 5 - src/pages/auth/SignIn/SignInPage.tsx | 7 +- src/pages/auth/SignUp/SignUpPage.tsx | 184 --------------------------- 3 files changed, 1 insertion(+), 195 deletions(-) delete mode 100644 src/pages/auth/SignUp/SignUpPage.tsx diff --git a/src/main.tsx b/src/main.tsx index 295c6d3..7b3e9a3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,7 +16,6 @@ import GrowingDegreeDaysPage from '@pages/dashboard/services/PestAndDisease/Grow import CompostOperationsReportPage from '@pages/dashboard/services/FarmCalendar/ReportingService/CompostOperations.tsx'; import AuthLayout from '@layouts/auth.tsx'; import SignInPage from '@pages/auth/SignIn/SignInPage.tsx'; -import SignUpPage from '@pages/auth/SignUp/SignUpPage.tsx'; import FarmParcelPage from '@pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcel.tsx'; import FarmParcelsPage from '@pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcels.tsx'; import FarmsPage from '@pages/dashboard/services/FarmCalendar/FarmLocations/Farms.tsx'; @@ -194,10 +193,6 @@ const router = createBrowserRouter([ path: 'sign-in', Component: SignInPage }, - { - path: 'sign-up', - Component: SignUpPage - }, { path: 'session-refresh', Component: TokenRefreshPage diff --git a/src/pages/auth/SignIn/SignInPage.tsx b/src/pages/auth/SignIn/SignInPage.tsx index c5dae5d..efa8fed 100644 --- a/src/pages/auth/SignIn/SignInPage.tsx +++ b/src/pages/auth/SignIn/SignInPage.tsx @@ -1,4 +1,4 @@ -import { Box, Button, IconButton, InputAdornment, Link, TextField, Typography } from "@mui/material"; +import { Box, Button, IconButton, InputAdornment, TextField, Typography } from "@mui/material"; import { useEffect, useState } from "react"; import LoginIcon from '@mui/icons-material/Login'; @@ -118,11 +118,6 @@ const SignInPage = () => { variant="contained"> Sign In - - - {'Don\'t have an account yet? Register for free'} - - { - const [credentials, setCredentials] = useState({ email: "", password: "", confirmPassword: "" }); - const [errors, setErrors] = useState({ email: "", password: "", confirmPassword: "" }); - const [showPassword, setShowPassword] = useState(false); - - const validateEmail = (email: string) => { - if (!email) return "Email is required"; - if (!/^\S+@\S+\.\S+$/.test(email)) return "Invalid email format"; - return ""; - }; - - const validatePassword = (password: string) => { - if (!password) return "Password is required"; - if (password.length < 6) return "Password must be at least 6 characters"; - return ""; - }; - - const validateConfirmPassword = (confirmPassword: string) => { - if (!confirmPassword) return "Confirm password is required"; - if (confirmPassword !== credentials.password) return "Passwords do not match"; - return ""; - }; - - const validateField = (name: string, value: string) => { - let error = ""; - if (name === "email") error = validateEmail(value); - if (name === "password") error = validatePassword(value); - if (name === "confirmPassword") error = validateConfirmPassword(value); - setErrors((prev) => ({ ...prev, [name]: error })); - }; - - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setCredentials((prev) => ({ ...prev, [name]: value })); - validateField(name, value); - }; - - const { fetchData, loading, response, error } = useFetch<{ message: string }>( - "user/register", // TODO: use real API - { - headers: { - "Content-Type": "application/json", - }, - method: "POST", - body: { email: credentials.email, password: credentials.password }, - } - ); - - const handleSubmit = async () => { - const emailError = validateEmail(credentials.email); - const passwordError = validatePassword(credentials.password); - const confirmPasswordError = validateConfirmPassword(credentials.confirmPassword); - - setErrors({ email: emailError, password: passwordError, confirmPassword: confirmPasswordError }); - - if (emailError || passwordError || confirmPasswordError) return; - - await fetchData(); - }; - - const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); - const navigate = useNavigate(); - - useEffect(() => { - if (response) { - setTimeout(() => navigate("/sign-in"), 1000); - } - }, [response]); - - useEffect(() => { - if (error) { - showSnackbar("error", error?.message.toString() ?? "Registration failed"); - } - }, [error]); - - return ( - <> - - - - Sign Up - - - setShowPassword((show) => !show)} - edge="end" - > - {showPassword ? : } - - - ), - } - }} - /> - - setShowPassword((show) => !show)} - edge="end" - > - {showPassword ? : } - - - ), - } - }} - /> - - - - {"Already have an account? Sign in"} - - - - - - - ) -} - -export default SignUpPage; \ No newline at end of file From ad60b6f03b522fdfe8234dd424914f2e685ee4ed Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Fri, 29 May 2026 11:35:46 +0200 Subject: [PATCH 74/91] implemented PWA - generated all images, configured manifest, updated readme --- README.md | 27 +++++++++++++++++++++++++++ index.html | 3 ++- package.json | 3 ++- public/apple-touch-icon-180x180.png | Bin 0 -> 2548 bytes public/favicon.ico | Bin 0 -> 1485 bytes public/logo.png | Bin 0 -> 157927 bytes public/maskable-icon-512x512.png | Bin 0 -> 11217 bytes public/pwa-192x192.png | Bin 0 -> 5400 bytes public/pwa-512x512.png | Bin 0 -> 20723 bytes public/pwa-64x64.png | Bin 0 -> 1196 bytes public/screenshot-mobile.png | Bin 0 -> 58648 bytes public/screenshot-wide.png | Bin 0 -> 59117 bytes pwa-assets.config.ts | 2 +- vite.config.ts | 25 +++++++++++++++++++++++++ 14 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 public/apple-touch-icon-180x180.png create mode 100644 public/favicon.ico create mode 100644 public/logo.png create mode 100644 public/maskable-icon-512x512.png create mode 100644 public/pwa-192x192.png create mode 100644 public/pwa-512x512.png create mode 100644 public/pwa-64x64.png create mode 100644 public/screenshot-mobile.png create mode 100644 public/screenshot-wide.png diff --git a/README.md b/README.md index 4741769..124900d 100644 --- a/README.md +++ b/README.md @@ -47,3 +47,30 @@ docker run --rm -it -p 80:80 openagri-dashboard ``` You can now visit the page [http://127.0.0.1](http://127.0.0.1) to access the WEB Dashboard + +# Using as a PWA +The dashboard is a Progressive Web App, so it can be installed and run like a native app on desktop and mobile, with offline support for the app shell. + +A service worker is registered automatically (`registerType: 'autoUpdate'`), so an installed app silently updates to the latest version on the next launch. + +> **Note:** Installing requires a secure context โ€” HTTPS, or `localhost` during local development. Plain `http://` over the LAN will not offer installation. + +### Install +- **Desktop (Chrome/Edge):** click the install icon at the right of the address bar, or open the โ‹ฎ menu โ†’ *Install OpenAgri-UserDashboardโ€ฆ* +- **Android (Chrome):** โ‹ฎ menu โ†’ *Install app* / *Add to Home screen* +- **iOS (Safari):** Share โ†’ *Add to Home Screen* + +The installed app launches in its own standalone window using the OpenAgri logo as its icon. + +### Regenerating icons +App icons (favicon, Apple touch icon, and the `pwa-*`/`maskable` PNGs) are generated from `public/logo.png`. After changing that image, regenerate them with: + +``` +npm run generate-pwa-assets +``` + +### Testing on a mobile device +The production build (`npm run build` + `npm run preview`) serves the service worker reliably (the dev server's is best-effort). To install on a phone you need an HTTPS origin: + +- **Android:** Chrome `chrome://inspect` โ†’ *Port forwarding* maps the phone's `localhost:4173` to your machine โ€” this counts as a secure context and keeps the same origin the backend expects. +- **Any device:** expose the preview over an HTTPS tunnel, e.g. `npx cloudflared tunnel --url http://localhost:4173`. Tunnel hostnames are already allowed in `vite.config.ts` (`preview.allowedHosts`). Note that API calls still require the tunnel origin to be on the backend's CORS allow-list. diff --git a/index.html b/index.html index 22abaee..4951e47 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,8 @@ - OpenAgri-UserDashboard + TS + + OpenAgri-UserDashboard diff --git a/package.json b/package.json index 17e1c22..873c7c0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "generate-pwa-assets": "pwa-assets-generator" }, "dependencies": { "@emotion/react": "^11.14.0", diff --git a/public/apple-touch-icon-180x180.png b/public/apple-touch-icon-180x180.png new file mode 100644 index 0000000000000000000000000000000000000000..b6e22682c2f67d1fd8925ce2a6c7e1e5b3445e61 GIT binary patch literal 2548 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 KMNUMnLSTY}v-XDo literal 0 HcmV?d00001 diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ea64619d09d63fe8c74188361d767355b7f9ccbd GIT binary patch literal 1485 zcmV;;1v2^o0096205C8B009690JjAI02TlM0EtjeM-2)Z3IG5A4M|8uQUCw|FaQ7m zFbDEe76$Qc+bSa2s$GD;iKY)@VB?Vd}Yx6Q-MIa(98mdTilzc!0 zn(I5O@zLFvy^tV^tE}yP%*>hdxSLx2;gj0GylVe7f!0oMZdL%To!;Cm_qkN?^$PAg z(}UKx_7+1AEDEBv)0;ayesuld=<(9Df=s0j{%$GC-VefhTh3&)j5zyUY5p_T9(f9DE`0MJ+jJM@6L5Dk2H^kJn}B(kN!2crSN z-b$f}kTk3TkOVzEyKw;`w2elD1S*L@2s{;6g#h?o`*j1kvD%GCtIrRAB&!>b#vqr^ z2{Q6t*W3bdMgoNLh~$E&%Iaq&MBt6>s0W~HFFyXd|MF{at#Y)j5VV}t+G zNERl5uv?$=Gn7y4(v3$Nm9&Sk3IP-W9uM#At35zcD-l+F)V!TVB2|6uvU&qV0(MEj z7wp7Jj+JI84GfFK7%6;4L}o6d3Id>M8?8=d?g5n*=of!XLWmUmL*D%|A7qbRjrYE{ zqe2=H{_c|7TMZEygS|D-21egQq@v;!Qj9l2m7)3}dofQeA%I8DAa@eKA3a{8Jy@Zr zzO$$=7*iAAg?K1SBkMhZ=sDVXCz5|_ZxP=CR7lm97l_FW;I8R)<53Z7=Bf_xv;gb9 zmRA7OCZ_5+(%pDu)z1{H{pKm}6B-ChS%_l;Rd&i4`w%1^$OQoG8&uN{duaqVq{^|k zle?!#HVA>&N@C|Ej@zrAfKIha9@hX*nw7!?PMQn6h$A(VE96Zo zm`7lua)ChG+lLlHF8N-=NFxHoQaA#*h*O~M$nXGL7TIM9z!Kor)75vs)l4c?Hy+_T zHcRG^6T|ls6qBrhF10R$0ElQFsEyd*s2E?TgrYF9TkFOn0&ZZ(#!ioh=&htSC63Yr z_{J=;k)LsN8fD_rQlUKHxi|;+aa&Mdb{l@r;jH9^aOZdWk#$080sxDPb(+*D@Ig8c zg}@Uy{qC;CUSn%-k^5;|fvCO?e5DE4C}=Kd)xfc>|F$EuQ*#f2l@_gr?;BKq=5ZP~ z(UQv2#)Q`L$EYZ{#Q%PWXI})Y_@#+!pF0x)t3-&zgyLqfW5P3ELJe!pM^Yz&tkPMS z0R0aoG?|k3pk7e|2~{7pSZac|$F`wu`K%&Q$YrRnJ2Vnb(M~^Nlhf%l1k{#P?iQYyX_Z62anke4fS;Oxg2cn@8#bP!1TYf2A@QWS z5E=~*j6MG-fhyNKKW*mCJXeYUfU>H^Q>gw|2+%$N2>;XjTp|D0uMz>fIpSzu_5Ev? nVWBFC)c!TV^{WtpDhd1sq47y{z-YRv00000NkvXXu0mjf*2kf1 literal 0 HcmV?d00001 diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ea5f8f765c92b06ed7d044a3fe6dac7abd216972 GIT binary patch literal 157927 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%0DCto^ZRIjscesZh(?|A*!4?g?ib<-Q-tGsOY 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 literal 0 HcmV?d00001 diff --git a/public/pwa-192x192.png b/public/pwa-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..aafeee70e691292808862a3bcc03d0f06af1b1fe GIT binary patch literal 5400 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 HF46x398C!j literal 0 HcmV?d00001 diff --git a/public/pwa-64x64.png b/public/pwa-64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..f1d22481613dd1487a489d3e4cfa5a16aa9194e9 GIT binary patch literal 1196 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 literal 0 HcmV?d00001 diff --git a/public/screenshot-wide.png b/public/screenshot-wide.png new file mode 100644 index 0000000000000000000000000000000000000000..76dc5dba55dad5dc05ac3d59c7b0a98394844cb7 GIT binary patch literal 59117 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ξ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 literal 0 HcmV?d00001 diff --git a/pwa-assets.config.ts b/pwa-assets.config.ts index 452b31f..592963d 100644 --- a/pwa-assets.config.ts +++ b/pwa-assets.config.ts @@ -8,5 +8,5 @@ export default defineConfig({ preset: '2023', }, preset, - images: ['public/favicon.svg'], + images: ['public/logo.png'], }) diff --git a/vite.config.ts b/vite.config.ts index bebea7e..70f5900 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,10 @@ import tsconfigPaths from 'vite-tsconfig-paths'; // https://vitejs.dev/config/ export default defineConfig({ + preview: { + // allow cloudflared/ngrok tunnel hosts for mobile PWA testing + allowedHosts: ['.trycloudflare.com', '.ngrok-free.app'], + }, plugins: [react(), tsconfigPaths(), VitePWA({ registerType: 'autoUpdate', injectRegister: 'auto', @@ -15,10 +19,31 @@ export default defineConfig({ }, manifest: { + id: '/', name: 'OpenAgri-UserDashboard', short_name: 'OAUserDash', description: 'Dashboard for all OpenAgri services', theme_color: '#558bc9', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait', + start_url: '/', + scope: '/', + screenshots: [ + { + src: 'screenshot-wide.png', + sizes: '1280x720', + type: 'image/png', + form_factor: 'wide', + label: 'OpenAgri UserDashboard', + }, + { + src: 'screenshot-mobile.png', + sizes: '720x1280', + type: 'image/png', + label: 'OpenAgri UserDashboard', + }, + ], }, workbox: { From 5c9bb15da493f8be9b0620c55d41ea62aa10eba2 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 9 Jun 2026 15:56:01 +0200 Subject: [PATCH 75/91] added runtime SW caching, offline chip, logout cache wipe Caches reference catalogs (SWR), user-scoped data (NetworkFirst), and forecasts (CacheFirst) so the app stays usable offline. Snackbar rewrites error toasts to warnings while offline so users see a friendly message instead of generic fetch errors. Toolbar shows an offline chip when navigator.onLine reports false. --- README.md | 16 +++++++ .../ToolbarActions/ToolbarActions.tsx | 15 ++++--- .../GenericSnackbar/GenericSnackbar.types.ts | 2 +- .../shared/OfflineChip/OfflineChip.tsx | 34 +++++++++++++++ src/hooks/useSnackbar.ts | 10 ++++- src/layouts/dashboard.tsx | 7 ++- src/utils/pwaCache.ts | 9 ++++ vite.config.ts | 43 +++++++++++++++++++ 8 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 src/components/shared/OfflineChip/OfflineChip.tsx create mode 100644 src/utils/pwaCache.ts diff --git a/README.md b/README.md index 124900d..5d468f6 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,22 @@ The dashboard is a Progressive Web App, so it can be installed and run like a na A service worker is registered automatically (`registerType: 'autoUpdate'`), so an installed app silently updates to the latest version on the next launch. +### Runtime caching +The service worker also caches API responses to keep the dashboard usable on poor or absent network. Strategies per endpoint group: + +| Cache | Strategy | TTL | Scope | +|---|---|---|---| +| `oa-reference` | StaleWhileRevalidate | 7 days | Global catalogs (activity types, crops, diseases, threat models, soil types, ETo option types) | +| `oa-user-semi` | NetworkFirst (5s timeout) | 1 day | User-scoped semi-static (Farm, FarmParcels, FarmAnimals, AgriculturalMachines, Pesticides, Fertilizers, `me/`) | +| `oa-user-live` | NetworkFirst (3s timeout) | 1 hour | User-scoped live data (FarmCalendarActivities, Observations, Alerts, CompostOperations, IrrigationOperations, irrigation datasets/calculations) | +| `oa-forecast` | CacheFirst | 30 min | Weather/risk forecasts | + +User-scoped caches (`oa-user-*`) are wiped on sign-out to prevent cross-user data leak on the same device. Reference and forecast caches survive. + +> **Offline writes are NOT supported yet.** Mutations (POST/PATCH/DELETE) bypass the service worker entirely โ€” they fail without a live connection. A future round will add Workbox Background Sync to queue mutations and replay them on reconnect. + +An `OfflineChip` appears in the toolbar whenever `navigator.onLine` reports offline, with a tooltip explaining that data shown may be stale and submissions will fail. + > **Note:** Installing requires a secure context โ€” HTTPS, or `localhost` during local development. Plain `http://` over the LAN will not offer installation. ### Install diff --git a/src/components/dashboard/ToolbarActions/ToolbarActions.tsx b/src/components/dashboard/ToolbarActions/ToolbarActions.tsx index 72b0455..0e7b923 100644 --- a/src/components/dashboard/ToolbarActions/ToolbarActions.tsx +++ b/src/components/dashboard/ToolbarActions/ToolbarActions.tsx @@ -1,4 +1,4 @@ -import { IconButton, ListItemIcon, ListItemText, MenuItem, MenuList, Paper, Popover, Tooltip } from "@mui/material"; +import { IconButton, ListItemIcon, ListItemText, MenuItem, MenuList, Paper, Popover, Stack, Tooltip } from "@mui/material"; import { useCallback, useEffect, useState } from "react"; import PersonIcon from '@mui/icons-material/Person'; @@ -6,6 +6,8 @@ import LogoutIcon from '@mui/icons-material/Logout'; import { useNavigate } from "react-router-dom"; import { useSession } from "@contexts/SessionContext"; import useFetch from "@hooks/useFetch"; +import { clearUserCaches } from "@utils/pwaCache"; +import OfflineChip from "@components/shared/OfflineChip/OfflineChip"; const ToolbarActions = () => { const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -33,8 +35,10 @@ const ToolbarActions = () => { useEffect(() => { if (response || error) { - setSession(null); - navigate("/"); + clearUserCaches().finally(() => { + setSession(null); + navigate("/"); + }); } }, [response, error]); @@ -43,7 +47,8 @@ const ToolbarActions = () => { } return ( - <> + +

@@ -79,7 +84,7 @@ const ToolbarActions = () => { - + ); } diff --git a/src/components/shared/GenericSnackbar/GenericSnackbar.types.ts b/src/components/shared/GenericSnackbar/GenericSnackbar.types.ts index 0114fc8..d8b0d0c 100644 --- a/src/components/shared/GenericSnackbar/GenericSnackbar.types.ts +++ b/src/components/shared/GenericSnackbar/GenericSnackbar.types.ts @@ -1,4 +1,4 @@ -type SnackbarType = 'success' | 'error' | 'info'; +type SnackbarType = 'success' | 'error' | 'info' | 'warning'; export interface GenericSnackbarProps { type: SnackbarType; diff --git a/src/components/shared/OfflineChip/OfflineChip.tsx b/src/components/shared/OfflineChip/OfflineChip.tsx new file mode 100644 index 0000000..5d5d345 --- /dev/null +++ b/src/components/shared/OfflineChip/OfflineChip.tsx @@ -0,0 +1,34 @@ +import { Chip, Tooltip } from "@mui/material"; +import CloudOffIcon from '@mui/icons-material/CloudOff'; +import { useEffect, useState } from "react"; + +const OfflineChip = () => { + const [offline, setOffline] = useState(!navigator.onLine); + + useEffect(() => { + const handleOnline = () => setOffline(false); + const handleOffline = () => setOffline(true); + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + if (!offline) return null; + + return ( + + } + label="Offline" + color="warning" + size="medium" + variant="filled" + /> + + ); +}; + +export default OfflineChip; diff --git a/src/hooks/useSnackbar.ts b/src/hooks/useSnackbar.ts index ca5056f..718c559 100644 --- a/src/hooks/useSnackbar.ts +++ b/src/hooks/useSnackbar.ts @@ -1,6 +1,6 @@ import { useState } from 'react'; -type SnackbarType = 'success' | 'error' | 'info'; +type SnackbarType = 'success' | 'error' | 'info' | 'warning'; interface SnackbarState { type: SnackbarType; @@ -16,6 +16,14 @@ const useSnackbar = () => { }); const showSnackbar = (type: SnackbarType, message: string) => { + if (type === 'error' && !navigator.onLine) { + setSnackbarState({ + open: true, + type: 'warning', + message: 'Failed to fetch โ€” no internet connection. Showing cached data where available.', + }); + return; + } setSnackbarState({ open: true, type, message }); }; diff --git a/src/layouts/dashboard.tsx b/src/layouts/dashboard.tsx index 54641bd..f015286 100644 --- a/src/layouts/dashboard.tsx +++ b/src/layouts/dashboard.tsx @@ -9,6 +9,7 @@ import { jwtDecode } from 'jwt-decode'; import Footer from '@components/shared/Footer'; import { useEffect, useState, useCallback } from 'react'; import useFetch from '@hooks/useFetch'; +import { clearUserCaches } from '@utils/pwaCache'; export type DashboardContextType = { setPageTitle: (title: string | undefined) => void; @@ -52,8 +53,10 @@ export default function DashLayout() { useEffect(() => { if (error) { - setSession(null); - navigate("/"); + clearUserCaches().finally(() => { + setSession(null); + navigate("/"); + }); } }, [error]) diff --git a/src/utils/pwaCache.ts b/src/utils/pwaCache.ts new file mode 100644 index 0000000..d23f003 --- /dev/null +++ b/src/utils/pwaCache.ts @@ -0,0 +1,9 @@ +export const clearUserCaches = async (): Promise => { + if (!('caches' in window)) return; + const names = await caches.keys(); + await Promise.all( + names + .filter(n => n.startsWith('oa-user-')) + .map(n => caches.delete(n)) + ); +}; diff --git a/vite.config.ts b/vite.config.ts index 70f5900..3c0c805 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -63,6 +63,49 @@ export default defineConfig({ urlPattern: /^\/examples\//, handler: 'NetworkOnly', }, + { + urlPattern: ({ request }) => + request.method === 'GET' && + /\/proxy\/(farmcalendar\/api\/v1\/FarmCalendarActivityTypes\/|pdm\/api\/v1\/(crop|disease|model|threat-model)\/?|irrigation\/api\/v1\/(dataset\/soil-types|eto\/option-types)\/?)/.test(request.url), + handler: 'StaleWhileRevalidate', + options: { + cacheName: 'oa-reference', + expiration: { maxEntries: 100, maxAgeSeconds: 7 * 24 * 60 * 60 }, + }, + }, + { + urlPattern: ({ request }) => + request.method === 'GET' && + (/\/proxy\/farmcalendar\/api\/v1\/(Farm|FarmParcels|FarmAnimals|AgriculturalMachines|Pesticides|Fertilizers)\//.test(request.url) + || /\/me\/?(\?|$)/.test(request.url)), + handler: 'NetworkFirst', + options: { + cacheName: 'oa-user-semi', + networkTimeoutSeconds: 5, + expiration: { maxEntries: 100, maxAgeSeconds: 24 * 60 * 60 }, + }, + }, + { + urlPattern: ({ request }) => + request.method === 'GET' && + /\/proxy\/(farmcalendar\/api\/v1\/(FarmCalendarActivities|Observations|Alerts|CompostOperations|IrrigationOperations)\/|irrigation\/api\/v1\/(dataset|eto\/get-calculations)\/)/.test(request.url), + handler: 'NetworkFirst', + options: { + cacheName: 'oa-user-live', + networkTimeoutSeconds: 3, + expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 }, + }, + }, + { + urlPattern: ({ request }) => + request.method === 'GET' && + /\/proxy\/(weather_data\/api\/data\/(forecast5|flight-forecast5|spray-forecast)\/?|pdm\/api\/v1\/risk-forecast)/.test(request.url), + handler: 'CacheFirst', + options: { + cacheName: 'oa-forecast', + expiration: { maxEntries: 50, maxAgeSeconds: 30 * 60 }, + }, + }, ], }, From 5dfe9102d5dd2096f642676dd42a4865d70e5c27 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 9 Jun 2026 16:49:59 +0200 Subject: [PATCH 76/91] fonts get precached --- vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index 3c0c805..d365a1c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -47,7 +47,7 @@ export default defineConfig({ }, workbox: { - globPatterns: ['**/*.{js,css,html,svg,png,ico}'], + globPatterns: ['**/*.{js,css,html,svg,png,ico,woff,woff2,ttf,otf}'], cleanupOutdatedCaches: true, clientsClaim: true, // also make sure it's not pre-cached From 453de0ce3807d9260624bf3d54dfbb1327c30a06 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 9 Jun 2026 16:51:55 +0200 Subject: [PATCH 77/91] modified how token refreshing works while offline - removed stale token guard so offline users don't get locked out of the dash after active token expires --- src/layouts/dashboard.tsx | 6 ++++-- src/pages/auth/TokenRefresh/TokenRefresh.tsx | 10 +++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/layouts/dashboard.tsx b/src/layouts/dashboard.tsx index f015286..919101d 100644 --- a/src/layouts/dashboard.tsx +++ b/src/layouts/dashboard.tsx @@ -53,6 +53,7 @@ export default function DashLayout() { useEffect(() => { if (error) { + if (!navigator.onLine) return; clearUserCaches().finally(() => { setSession(null); navigate("/"); @@ -80,9 +81,10 @@ export default function DashLayout() { // Decode the token to get its payload const decodedToken = jwtDecode(session.user.token); - // Check if the token has expired if ((decodedToken.exp ?? 0) < Date.now() / 1000) { - return ; + if (navigator.onLine) { + return ; + } } } catch (error) { // Token is invalid; redirect to sign-in diff --git a/src/pages/auth/TokenRefresh/TokenRefresh.tsx b/src/pages/auth/TokenRefresh/TokenRefresh.tsx index a3bdf6a..4c7f53c 100644 --- a/src/pages/auth/TokenRefresh/TokenRefresh.tsx +++ b/src/pages/auth/TokenRefresh/TokenRefresh.tsx @@ -57,14 +57,22 @@ const TokenRefreshPage = () => { } }, [session]); - // Navigate back to the sign in page in case of error useEffect(() => { if (error) { + if (!navigator.onLine) { + return; + } const callbackURL = encodeURIComponent(searchParams.get("callbackURL") ?? '/') navigate('/sign-in?callbackURL=' + callbackURL); } }, [error]) + useEffect(() => { + const handleOnline = () => fetchData(); + window.addEventListener('online', handleOnline); + return () => window.removeEventListener('online', handleOnline); + }, []); + return ( From dc1b64dd3ff2a0508ec224afe570b80034ff6361 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Wed, 10 Jun 2026 14:27:40 +0200 Subject: [PATCH 78/91] implemented prepare for offline button and caching logic, adjusted farm calendar data fetching to align with this precaching method --- .../PrepareOffline/PrepareOfflineButton.tsx | 27 ++ .../PrepareOffline/PrepareOfflineModal.tsx | 284 ++++++++++++++++++ .../ToolbarActions/ToolbarActions.tsx | 2 + .../services/FarmCalendar/FarmCalendar.tsx | 53 +++- src/utils/prefetchOffline.ts | 112 +++++++ 5 files changed, 465 insertions(+), 13 deletions(-) create mode 100644 src/components/dashboard/PrepareOffline/PrepareOfflineButton.tsx create mode 100644 src/components/dashboard/PrepareOffline/PrepareOfflineModal.tsx create mode 100644 src/utils/prefetchOffline.ts diff --git a/src/components/dashboard/PrepareOffline/PrepareOfflineButton.tsx b/src/components/dashboard/PrepareOffline/PrepareOfflineButton.tsx new file mode 100644 index 0000000..44f1cef --- /dev/null +++ b/src/components/dashboard/PrepareOffline/PrepareOfflineButton.tsx @@ -0,0 +1,27 @@ +import { useState } from "react"; +import { IconButton, Tooltip } from "@mui/material"; +import DownloadForOfflineIcon from '@mui/icons-material/DownloadForOffline'; +import PrepareOfflineModal from "./PrepareOfflineModal"; + +const PrepareOfflineButton = () => { + const [open, setOpen] = useState(false); + + return ( + <> + + + setOpen(true)} + disabled={!navigator.onLine} + aria-label="prepare offline" + > + + + + + setOpen(false)} /> + + ); +}; + +export default PrepareOfflineButton; diff --git a/src/components/dashboard/PrepareOffline/PrepareOfflineModal.tsx b/src/components/dashboard/PrepareOffline/PrepareOfflineModal.tsx new file mode 100644 index 0000000..d2b0558 --- /dev/null +++ b/src/components/dashboard/PrepareOffline/PrepareOfflineModal.tsx @@ -0,0 +1,284 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + LinearProgress, + List, + ListItem, + ListItemText, + Stack, + Typography, +} from "@mui/material"; +import { DatePicker } from "@mui/x-date-pickers"; +import { Dayjs } from "dayjs"; +import { jwtDecode } from "jwt-decode"; +import { useSession } from "@contexts/SessionContext"; +import useFetch from "@hooks/useFetch"; +import { FarmParcelModel } from "@models/FarmParcel"; +import { + buildPrefetchURLs, + defaultDateRange, + MAX_RANGE_DAYS, + runPrefetch, +} from "@utils/prefetchOffline"; + +interface PrepareOfflineModalProps { + open: boolean; + onClose: () => void; +} + +const REFRESH_THRESHOLD_SECONDS = 5 * 60; +const CONCURRENCY = 6; + +const PrepareOfflineModal: React.FC = ({ open, onClose }) => { + const { session, setSession } = useSession(); + + const [from, setFrom] = useState(defaultDateRange().from); + const [to, setTo] = useState(defaultDateRange().to); + const [selectedParcels, setSelectedParcels] = useState>(new Set()); + const [parcels, setParcels] = useState([]); + + const [running, setRunning] = useState(false); + const [done, setDone] = useState(false); + const [progress, setProgress] = useState({ done: 0, total: 0 }); + const [failures, setFailures] = useState<{ url: string; status: number }[]>([]); + + const { fetchData: fetchParcels, response: parcelsResponse } = useFetch( + 'proxy/farmcalendar/api/v1/FarmParcels/?format=json', + { method: 'GET' }, + ); + + useEffect(() => { + if (open) { + fetchParcels(); + } + }, [open]); + + useEffect(() => { + if (parcelsResponse) { + setParcels(parcelsResponse); + const currentParcelId = session?.farm_parcel?.["@id"].split(':').pop(); + if (currentParcelId) { + setSelectedParcels(new Set([currentParcelId])); + } + } + }, [parcelsResponse]); + + const rangeDays = useMemo(() => { + if (!from || !to) return 0; + return to.diff(from, 'day'); + }, [from, to]); + + const rangeInvalid = !from || !to || to.isBefore(from) || rangeDays > MAX_RANGE_DAYS; + + const toggleParcel = (id: string) => { + setSelectedParcels(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const selectAll = () => setSelectedParcels(new Set(parcels.map(p => p["@id"].split(':').pop() ?? ''))); + const deselectAll = () => setSelectedParcels(new Set()); + + const refreshAccessToken = async (refresh: string): Promise => { + const apiUrl = (window as any).env?.VITE_API_URL ?? import.meta.env.VITE_API_URL; + try { + const res = await fetch(apiUrl + 'token/refresh/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh }), + }); + if (!res.ok) return null; + const data = await res.json() as { access: string }; + return data.access; + } catch { + return null; + } + }; + + const startPrefetch = async () => { + if (!session?.user.token || !from || !to) return; + setRunning(true); + setDone(false); + setFailures([]); + setProgress({ done: 0, total: 0 }); + + let token = session.user.token; + let needsRefresh = false; + try { + const decoded = jwtDecode<{ exp?: number }>(token); + const expiresIn = (decoded.exp ?? 0) - Date.now() / 1000; + if (expiresIn < REFRESH_THRESHOLD_SECONDS) needsRefresh = true; + } catch { + needsRefresh = true; + } + + if (needsRefresh) { + const refresh = session.user.refresh_token; + const newToken = refresh ? await refreshAccessToken(refresh) : null; + if (!newToken) { + setRunning(false); + setFailures([{ url: 'token/refresh/', status: 0 }]); + setDone(true); + return; + } + token = newToken; + setSession(prev => prev ? { ...prev, user: { ...prev.user, token: newToken } } : null); + } + + const selectedParcelObjects = parcels + .filter(p => selectedParcels.has(p["@id"].split(':').pop() ?? '')) + .map(p => ({ + id: p["@id"].split(':').pop() ?? '', + lat: p.location?.lat ?? null, + lon: p.location?.long ?? null, + })); + const urls = buildPrefetchURLs(selectedParcelObjects, from, to); + + const results = await runPrefetch(urls, token, CONCURRENCY, (d, t, fail) => { + setProgress({ done: d, total: t }); + if (fail) setFailures(prev => [...prev, { url: fail.url, status: fail.status }]); + }); + + setRunning(false); + setDone(true); + const failed = results.filter(r => !r.ok).length; + setProgress({ done: results.length - failed, total: results.length }); + }; + + const handleClose = () => { + if (running) return; + setDone(false); + setProgress({ done: 0, total: 0 }); + setFailures([]); + onClose(); + }; + + const percent = progress.total > 0 ? (progress.done / progress.total) * 100 : 0; + const canStart = !running && !rangeInvalid && selectedParcels.size > 0; + + return ( + + Prepare for offline use + + + + Pick a date range and parcels. Data for the selected range will be cached so you can use the app offline. + + + + + + + {rangeInvalid && ( + + Date range must be valid and span at most {MAX_RANGE_DAYS} days. + + )} + + + + Parcels ({selectedParcels.size}/{parcels.length}) + + + + + + + {parcels.length === 0 && ( + No parcels found. + )} + {parcels.map(p => { + const id = p["@id"].split(':').pop() ?? ''; + return ( + toggleParcel(id)} + disabled={running} + /> + } + label={`${p.identifier} (${p.category})`} + /> + ); + })} + + + + {(running || done) && ( + + + + {done ? 'Done' : 'Caching...'} + + + {progress.done} / {progress.total} + + + + + )} + + {failures.length > 0 && ( + + + Failed endpoints ({failures.length}) + + + {failures.map((f, i) => ( + + + + ))} + + + )} + + + + + {!done && ( + + )} + + + ); +}; + +export default PrepareOfflineModal; diff --git a/src/components/dashboard/ToolbarActions/ToolbarActions.tsx b/src/components/dashboard/ToolbarActions/ToolbarActions.tsx index 0e7b923..e8bc670 100644 --- a/src/components/dashboard/ToolbarActions/ToolbarActions.tsx +++ b/src/components/dashboard/ToolbarActions/ToolbarActions.tsx @@ -8,6 +8,7 @@ import { useSession } from "@contexts/SessionContext"; import useFetch from "@hooks/useFetch"; import { clearUserCaches } from "@utils/pwaCache"; import OfflineChip from "@components/shared/OfflineChip/OfflineChip"; +import PrepareOfflineButton from "@components/dashboard/PrepareOffline/PrepareOfflineButton"; const ToolbarActions = () => { const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -49,6 +50,7 @@ const ToolbarActions = () => { return ( +
diff --git a/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx b/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx index 8b66b53..4105b70 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx @@ -26,12 +26,42 @@ const FarmCalendarPage = () => { const { session } = useSession(); - const { fetchData, response, error } = useFetch( - `proxy/farmcalendar/api/v1/FarmCalendarActivities/?parcel=${session?.farm_parcel?.["@id"].split(':')[3]}&format=json&fromDate=${dayjs(dateRange.start).format('YYYY-MM-DD')}&toDate=${dayjs(dateRange.end).format('YYYY-MM-DD')}`, - { - method: 'GET', + const [activities, setActivities] = useState([]); + const [activitiesError, setActivitiesError] = useState(null); + + const fetchActivitiesByMonth = async () => { + if (!dateRange.start || !dateRange.end || !session?.farm_parcel || !session.user.token) return; + const parcelId = session.farm_parcel["@id"].split(':')[3]; + const apiUrl = (window as any).env?.VITE_API_URL ?? import.meta.env.VITE_API_URL; + const months: { from: string; to: string }[] = []; + let cursor = dayjs(dateRange.start).startOf('month'); + const end = dayjs(dateRange.end).endOf('month'); + while (cursor.isBefore(end) || cursor.isSame(end, 'month')) { + months.push({ + from: cursor.startOf('month').format('YYYY-MM-DD'), + to: cursor.endOf('month').format('YYYY-MM-DD'), + }); + cursor = cursor.add(1, 'month'); } - ); + setActivitiesError(null); + try { + const responses = await Promise.all(months.map(m => + fetch(`${apiUrl}proxy/farmcalendar/api/v1/FarmCalendarActivities/?parcel=${parcelId}&format=json&fromDate=${m.from}&toDate=${m.to}`, { + headers: { Authorization: `Bearer ${session.user.token}` }, + }).then(r => r.ok ? r.json() as Promise : Promise.reject(new Error(`HTTP ${r.status}`))) + )); + const merged = responses.flat(); + const seen = new Set(); + const deduped = merged.filter(a => { + if (seen.has(a["@id"])) return false; + seen.add(a["@id"]); + return true; + }); + setActivities(deduped); + } catch (err) { + setActivitiesError(err as Error); + } + }; const { fetchData: activityTypesFetchData, response: activityTypesResponse, error: activityTypesError } = useFetch( `proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/?format=json`, @@ -60,21 +90,18 @@ const FarmCalendarPage = () => { useEffect(() => { if (dateRange.start && dateRange.end && session?.farm_parcel) { - fetchData(); + fetchActivitiesByMonth(); } }, [session?.farm_parcel, dateRange]) useEffect(() => { - if (error) { + if (activitiesError) { showSnackbar('error', 'Error loading activities'); } - }, [error]) + }, [activitiesError]) const calendarEvents = useMemo(() => { - if (!Array.isArray(response)) { - return []; - } - return response.map((event): EventInput => ({ + return activities.map((event): EventInput => ({ id: event['@id'], title: event.title, start: event.hasStartDatetime, @@ -84,7 +111,7 @@ const FarmCalendarPage = () => { activityType: event.activityType, } })); - }, [response]); + }, [activities]); return ( <> diff --git a/src/utils/prefetchOffline.ts b/src/utils/prefetchOffline.ts new file mode 100644 index 0000000..2c2fe18 --- /dev/null +++ b/src/utils/prefetchOffline.ts @@ -0,0 +1,112 @@ +import dayjs from "dayjs"; + +const GLOBAL_ENDPOINTS = [ + 'proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/?format=json', + 'proxy/farmcalendar/api/v1/Farm/?format=json', + 'proxy/farmcalendar/api/v1/FarmParcels/?format=json', + 'proxy/farmcalendar/api/v1/FarmAnimals/?format=json', + 'proxy/farmcalendar/api/v1/AgriculturalMachines/?format=json', + 'proxy/farmcalendar/api/v1/Pesticides/?format=json', + 'proxy/farmcalendar/api/v1/Fertilizers/?format=json', + 'proxy/farmcalendar/api/v1/Observations/?format=json', + 'proxy/pdm/api/v1/crop/', + 'proxy/pdm/api/v1/disease/', + 'proxy/pdm/api/v1/threat-model/', + 'proxy/irrigation/api/v1/dataset/soil-types/', + 'proxy/irrigation/api/v1/eto/option-types/', + 'me/', +]; + +export interface PrefetchParcel { + id: string; + lat: number | null; + lon: number | null; +} + +export const buildPrefetchURLs = ( + parcels: PrefetchParcel[], + from: dayjs.Dayjs, + to: dayjs.Dayjs, +): string[] => { + const urls = [...GLOBAL_ENDPOINTS]; + + const monthRanges: { from: string; to: string }[] = []; + let cursor = from.startOf('month'); + const end = to.endOf('month'); + while (cursor.isBefore(end) || cursor.isSame(end, 'month')) { + monthRanges.push({ + from: cursor.startOf('month').format('YYYY-MM-DD'), + to: cursor.endOf('month').format('YYYY-MM-DD'), + }); + cursor = cursor.add(1, 'month'); + } + + for (const p of parcels) { + for (const range of monthRanges) { + urls.push( + `proxy/farmcalendar/api/v1/FarmCalendarActivities/?parcel=${p.id}&format=json&fromDate=${range.from}&toDate=${range.to}`, + ); + } + if (p.lat !== null && p.lon !== null) { + urls.push( + `proxy/weather_data/api/data/forecast5/?lat=${p.lat}&lon=${p.lon}`, + `proxy/weather_data/api/data/spray-forecast/?lat=${p.lat}&lon=${p.lon}`, + `proxy/weather_data/api/data/flight-forecast5/?lat=${p.lat}&lon=${p.lon}`, + ); + } + } + return urls; +}; + +const getApiUrl = (): string => + (window as any).env?.VITE_API_URL ? (window as any).env.VITE_API_URL : import.meta.env.VITE_API_URL; + +interface PrefetchResult { + url: string; + ok: boolean; + status: number; +} + +const fetchOne = async (url: string, token: string): Promise => { + const apiUrl = getApiUrl(); + try { + const res = await fetch(apiUrl + url, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + }); + return { url, ok: res.ok, status: res.status }; + } catch { + return { url, ok: false, status: 0 }; + } +}; + +export const runPrefetch = async ( + urls: string[], + token: string, + concurrency: number, + onProgress: (done: number, total: number, lastFailure: PrefetchResult | null) => void, +): Promise => { + const results: PrefetchResult[] = []; + let cursor = 0; + + const worker = async () => { + while (cursor < urls.length) { + const idx = cursor++; + const result = await fetchOne(urls[idx], token); + results.push(result); + onProgress(results.length, urls.length, result.ok ? null : result); + } + }; + + await Promise.all(Array.from({ length: Math.min(concurrency, urls.length) }, worker)); + return results; +}; + +export const DEFAULT_FROM_OFFSET_DAYS = 30; +export const DEFAULT_TO_OFFSET_DAYS = 300; +export const MAX_RANGE_DAYS = 365; + +export const defaultDateRange = () => ({ + from: dayjs().subtract(DEFAULT_FROM_OFFSET_DAYS, 'day'), + to: dayjs().add(DEFAULT_TO_OFFSET_DAYS, 'day'), +}); From f3efded7aa0829e4898cc0b51c1920c6c1ef0fc0 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Wed, 10 Jun 2026 15:22:16 +0200 Subject: [PATCH 79/91] updated status messages for failed API calls and added retry button to precache modal --- .../PrepareOffline/PrepareOfflineModal.tsx | 30 ++++++++++++++++++- src/utils/prefetchOffline.ts | 19 ++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/components/dashboard/PrepareOffline/PrepareOfflineModal.tsx b/src/components/dashboard/PrepareOffline/PrepareOfflineModal.tsx index d2b0558..28296c3 100644 --- a/src/components/dashboard/PrepareOffline/PrepareOfflineModal.tsx +++ b/src/components/dashboard/PrepareOffline/PrepareOfflineModal.tsx @@ -27,6 +27,7 @@ import { defaultDateRange, MAX_RANGE_DAYS, runPrefetch, + statusText, } from "@utils/prefetchOffline"; interface PrepareOfflineModalProps { @@ -156,6 +157,28 @@ const PrepareOfflineModal: React.FC = ({ open, onClose setProgress({ done: results.length - failed, total: results.length }); }; + const retryFailed = async () => { + if (!session?.user.token || failures.length === 0) return; + const urlsToRetry = failures.map(f => f.url); + const baseSuccess = progress.total - failures.length; + setRunning(true); + setDone(false); + setFailures([]); + setProgress(p => ({ done: baseSuccess, total: p.total })); + + const newFailures: { url: string; status: number }[] = []; + let successCount = 0; + await runPrefetch(urlsToRetry, session.user.token, CONCURRENCY, (_d, _t, fail) => { + if (fail) newFailures.push({ url: fail.url, status: fail.status }); + else successCount++; + setProgress(p => ({ done: baseSuccess + successCount, total: p.total })); + setFailures([...newFailures]); + }); + + setRunning(false); + setDone(true); + }; + const handleClose = () => { if (running) return; setDone(false); @@ -257,7 +280,7 @@ const PrepareOfflineModal: React.FC = ({ open, onClose @@ -271,6 +294,11 @@ const PrepareOfflineModal: React.FC = ({ open, onClose + {done && failures.length > 0 && ( + + )} {!done && ( + + )} + { inputFocusedRef.current = true; }} + onBlur={() => { + inputFocusedRef.current = false; + if (inputError || !inputValue.trim()) { + setInputValue(value ?? ''); + setInputError(false); + } + }} + fullWidth + multiline + minRows={2} + maxRows={4} + slotProps={{ input: { readOnly: readOnly } }} + placeholder="Draw a polygon on the map or paste WKT here" + size="small" + error={inputError} + helperText={inputError ? 'Invalid WKT polygon' : ' '} + /> + + ); +}; + +export default WKTPolygonMap; diff --git a/src/components/shared/WKTPolygonMap/WKTPolygonMap.types.ts b/src/components/shared/WKTPolygonMap/WKTPolygonMap.types.ts new file mode 100644 index 0000000..4918513 --- /dev/null +++ b/src/components/shared/WKTPolygonMap/WKTPolygonMap.types.ts @@ -0,0 +1,7 @@ +export interface WKTPolygonMapProps { + value: string; + onChange: (wkt: string) => void; + readOnly?: boolean; + center?: { lat: number | null; long: number | null }; + height?: number; +} diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx index 57ce8a5..cae0742 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material"; +import { Box, Button, Checkbox, FormControlLabel, Stack, TextField, Typography } from "@mui/material"; import { AddFarmParcelProps } from "./AddFarmParcel.types"; import { useEffect, useState } from "react"; import { FarmParcelModel } from "@models/FarmParcel"; @@ -8,6 +8,7 @@ import AddIcon from '@mui/icons-material/Add'; import useFetch from "@hooks/useFetch"; import useSnackbar from "@hooks/useSnackbar"; import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import WKTPolygonMap from "@components/shared/WKTPolygonMap/WKTPolygonMap"; const AddFarmParcel: React.FC = ({ onAction }) => { @@ -39,7 +40,7 @@ const AddFarmParcel: React.FC = ({ onAction }) => { 'hasGeometry': { '@id': '', '@type': '', - 'asWKT': 'POLYGON((0 0, 0 0))' + 'asWKT': '' }, 'location': { '@id': '', @@ -117,11 +118,19 @@ const AddFarmParcel: React.FC = ({ onAction }) => { }); }; + const handleGeometryChange = (wkt: string) => { + setFormData(prev => { + if (!prev) return undefined; + return { ...prev, hasGeometry: { ...prev.hasGeometry, asWKT: wkt } }; + }); + }; + const isFormInvalid = !formData?.identifier?.trim() || !formData.category || !formData.location.lat || !formData.location.long || + !formData?.hasGeometry?.asWKT || !selectedFarm return ( @@ -167,6 +176,14 @@ const AddFarmParcel: React.FC = ({ onAction }) => { + + Parcel boundary * + + diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcel.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcel.tsx index 0976b6c..ebbbf09 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcel.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcel.tsx @@ -13,6 +13,7 @@ import DeleteIcon from '@mui/icons-material/Delete'; import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; import useDialog from "@hooks/useDialog"; import { ServiceContextType } from "@layouts/services/FarmCalendarLayout"; +import WKTPolygonMap from "@components/shared/WKTPolygonMap/WKTPolygonMap"; const FarmParcelPage = () => { const { actions } = useOutletContext(); @@ -133,11 +134,19 @@ const FarmParcelPage = () => { }); }; + const handleGeometryChange = (wkt: string) => { + setParcel(prev => { + if (!prev) return undefined; + return { ...prev, hasGeometry: { ...prev.hasGeometry, asWKT: wkt } }; + }); + }; + const isFormInvalid = !parcel?.identifier?.trim() || !parcel.category || !parcel.location.lat || !parcel.location.long || + !parcel?.hasGeometry?.asWKT || !selectedFarm return ( @@ -189,6 +198,15 @@ const FarmParcelPage = () => { + + Parcel boundary * + + From 059f02fd3ffe15cf72967cc16cd72d1c2214edd6 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 16 Jun 2026 11:56:45 +0200 Subject: [PATCH 81/91] Bumped maximumFileSizeToCacheInBytes to 5 MiB --- vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vite.config.ts b/vite.config.ts index d365a1c..89406a7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -48,6 +48,7 @@ export default defineConfig({ workbox: { globPatterns: ['**/*.{js,css,html,svg,png,ico,woff,woff2,ttf,otf}'], + maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, cleanupOutdatedCaches: true, clientsClaim: true, // also make sure it's not pre-cached From 27d5bef920ddf207a28e6a1797485bf1b1912838 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 23 Jun 2026 10:29:57 +0200 Subject: [PATCH 82/91] fix for farm parcel required fields --- .../AddFarmParcel/AddFarmParcel.tsx | 75 ++++++++++++------- .../FarmCalendar/FarmLocations/FarmParcel.tsx | 73 ++++++++++++------ 2 files changed, 100 insertions(+), 48 deletions(-) diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx index cae0742..f0ea550 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx @@ -10,6 +10,19 @@ import useSnackbar from "@hooks/useSnackbar"; import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; import WKTPolygonMap from "@components/shared/WKTPolygonMap/WKTPolygonMap"; +const REQUIRED_KEYS = new Set([ + 'identifier', + 'category', + 'farm', + 'hasGeometry.asWKT', + 'location.lat', + 'location.long', + 'validFrom', + 'validTo', +]); + +const isReq = (key: string) => REQUIRED_KEYS.has(key); + const AddFarmParcel: React.FC = ({ onAction }) => { const [selectedFarm, setSelectedFarm] = useState(''); @@ -63,21 +76,20 @@ const AddFarmParcel: React.FC = ({ onAction }) => { ); const handlePost = () => { - let body = { ...formData }; + const body = JSON.parse(JSON.stringify(formData)) as FarmParcelModel; (body.farm as { '@id': string })['@id'] = `urn:farmcalendar:Farm:${selectedFarm}`; - console.log("Form Data:", body); + if (typeof body.depiction === 'string' && !body.depiction.trim()) body.depiction = null; - fetchData({ body: body }); + fetchData({ body }); }; const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); - useEffect(() => { if (response) { onAction && onAction(); - showSnackbar('success', "Pest added successfully"); + showSnackbar('success', "Farm parcel added successfully"); } }, [response]); @@ -107,9 +119,9 @@ const AddFarmParcel: React.FC = ({ onAction }) => { currentLevel = currentLevel[keys[i]]; } - let finalValue: string | number | boolean = type === 'checkbox' ? checked : value; + let finalValue: string | number | boolean | null = type === 'checkbox' ? checked : value; if (isNumericField) { - finalValue = parseFloat(value); + finalValue = value === '' ? null : parseFloat(value); } currentLevel[keys[keys.length - 1]] = finalValue; @@ -125,13 +137,22 @@ const AddFarmParcel: React.FC = ({ onAction }) => { }); }; - const isFormInvalid = - !formData?.identifier?.trim() || - !formData.category || - !formData.location.lat || - !formData.location.long || - !formData?.hasGeometry?.asWKT || - !selectedFarm + const fieldEmpty = (key: string): boolean => { + if (!formData) return true; + switch (key) { + case 'identifier': return !formData.identifier?.trim(); + case 'category': return !formData.category?.trim(); + case 'farm': return !selectedFarm; + case 'hasGeometry.asWKT': return !formData.hasGeometry?.asWKT; + case 'location.lat': return formData.location.lat == null || Number.isNaN(formData.location.lat); + case 'location.long': return formData.location.long == null || Number.isNaN(formData.location.long); + case 'validFrom': return !formData.validFrom; + case 'validTo': return !formData.validTo; + default: return false; + } + }; + + const isFormInvalid = Array.from(REQUIRED_KEYS).some(k => fieldEmpty(k)); return ( <> @@ -140,20 +161,22 @@ const AddFarmParcel: React.FC = ({ onAction }) => { { endpoint='proxy/farmcalendar/api/v1/Farm/?format=json' - label='Selected farm *' + label='Selected farm' + required={isReq('farm')} + error={isReq('farm') && fieldEmpty('farm')} selectedValue={selectedFarm} setSelectedValue={setSelectedFarm} getOptionLabel={item => `${item.name}`} getOptionValue={item => item["@id"].split(':')[3]}> } - - + + - + - - + + @@ -168,16 +191,18 @@ const AddFarmParcel: React.FC = ({ onAction }) => { } label="Ground slope" /> - + - + - - + + - Parcel boundary * + + Parcel boundary{isReq('hasGeometry.asWKT') ? ' *' : ''} + ([ + 'identifier', + 'category', + 'farm', + 'hasGeometry.asWKT', + 'location.lat', + 'location.long', + 'validFrom', + 'validTo', +]); + +const isReq = (key: string) => REQUIRED_KEYS.has(key); + const FarmParcelPage = () => { const { actions } = useOutletContext(); const canEdit = actions.includes('edit'); @@ -98,9 +111,10 @@ const FarmParcelPage = () => { }; const handleEdit = () => { - editFetchData({ - body: parcel - }); + if (!parcel) return; + const body = JSON.parse(JSON.stringify(parcel)) as FarmParcelModel; + if (typeof body.depiction === 'string' && !body.depiction.trim()) body.depiction = null; + editFetchData({ body }); }; const handleChange = (e: React.ChangeEvent) => { @@ -123,9 +137,9 @@ const FarmParcelPage = () => { currentLevel = currentLevel[keys[i]]; } - let finalValue: string | number | boolean = type === 'checkbox' ? checked : value; + let finalValue: string | number | boolean | null = type === 'checkbox' ? checked : value; if (isNumericField) { - finalValue = parseFloat(value); + finalValue = value === '' ? null : parseFloat(value); } currentLevel[keys[keys.length - 1]] = finalValue; @@ -141,13 +155,22 @@ const FarmParcelPage = () => { }); }; - const isFormInvalid = - !parcel?.identifier?.trim() || - !parcel.category || - !parcel.location.lat || - !parcel.location.long || - !parcel?.hasGeometry?.asWKT || - !selectedFarm + const fieldEmpty = (key: string): boolean => { + if (!parcel) return true; + switch (key) { + case 'identifier': return !parcel.identifier?.trim(); + case 'category': return !parcel.category?.trim(); + case 'farm': return !selectedFarm; + case 'hasGeometry.asWKT': return !parcel.hasGeometry?.asWKT; + case 'location.lat': return parcel.location.lat == null || Number.isNaN(parcel.location.lat); + case 'location.long': return parcel.location.long == null || Number.isNaN(parcel.location.long); + case 'validFrom': return !parcel.validFrom; + case 'validTo': return !parcel.validTo; + default: return false; + } + }; + + const isFormInvalid = Array.from(REQUIRED_KEYS).some(k => fieldEmpty(k)); return ( <> @@ -162,20 +185,22 @@ const FarmParcelPage = () => { {parcel && canEdit={canEdit} endpoint='proxy/farmcalendar/api/v1/Farm/?format=json' - label='Selected farm *' + label='Selected farm' + required={isReq('farm')} + error={isReq('farm') && fieldEmpty('farm')} selectedValue={selectedFarm} setSelectedValue={setSelectedFarm} getOptionLabel={item => `${item.name}`} getOptionValue={item => item["@id"].split(':')[3]}> } - - + + - + - - + + @@ -190,16 +215,18 @@ const FarmParcelPage = () => { {}} />} label="Ground slope" /> - + - + - - + + - Parcel boundary * + + Parcel boundary{isReq('hasGeometry.asWKT') ? ' *' : ''} + Date: Wed, 24 Jun 2026 15:58:06 +0200 Subject: [PATCH 83/91] Implemented parcel viewing and accessing module to farm details view --- .../FarmCalendar/FarmLocations/Farm.tsx | 125 +++++++++++++++--- 1 file changed, 108 insertions(+), 17 deletions(-) diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farm.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farm.tsx index a4f319e..488ff30 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farm.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farm.tsx @@ -2,12 +2,14 @@ import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar" import useFetch from "@hooks/useFetch"; import useSnackbar from "@hooks/useSnackbar"; import { FarmModel } from "@models/Farm"; -import { Box, Button, Card, CardContent, Skeleton, Stack, TextField, Typography } from "@mui/material"; -import { useEffect, useState } from "react"; +import { FarmParcelModel } from "@models/FarmParcel"; +import { Box, Button, Card, CardActionArea, CardContent, Chip, Grid, Skeleton, Stack, TextField, Tooltip, Typography } from "@mui/material"; +import { useEffect, useMemo, useState } from "react"; import { useNavigate, useOutletContext, useParams } from "react-router-dom"; import SaveIcon from '@mui/icons-material/Save'; import DeleteIcon from '@mui/icons-material/Delete'; +import PolylineIcon from '@mui/icons-material/Polyline'; import useDialog from "@hooks/useDialog"; import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; import { ServiceContextType } from "@layouts/services/FarmCalendarLayout"; @@ -42,6 +44,11 @@ const FarmPage = () => { } ); + const { fetchData: fetchParcels, response: parcelsResponse, loading: parcelsLoading, error: parcelsError } = useFetch( + `proxy/farmcalendar/api/v1/FarmParcels/?format=json`, + { method: 'GET' }, + ); + const { dialogProps, showDialog } = useDialog(); const handleCloseDialog = () => { @@ -52,8 +59,20 @@ const FarmPage = () => { useEffect(() => { fetchData(); + fetchParcels(); }, []) + useEffect(() => { + if (parcelsError) { + showSnackbar('error', 'Error loading parcels'); + } + }, [parcelsError]) + + const farmParcels = useMemo(() => { + if (!Array.isArray(parcelsResponse) || !farm) return []; + return parcelsResponse.filter(p => p.farm?.["@id"] === farm["@id"]); + }, [parcelsResponse, farm]) + useEffect(() => { if (error) { showSnackbar('error', 'Error loading farm'); @@ -117,19 +136,19 @@ const FarmPage = () => { const isFormInvalid = - !farm?.name.trim() || - !farm.administrator || - !farm.description || - !farm.contactPerson.firstname || - !farm.contactPerson.lastname || - !farm.telephone || - !farm.vatID || - !farm.address.adminUnitL1 || - !farm.address.adminUnitL2 || - !farm.address.addressArea || - !farm.address.municipality || - !farm.address.community || - !farm.address.locatorName; + !farm?.name?.trim() || + !farm.administrator?.trim() || + !farm.description?.trim() || + !farm.contactPerson?.firstname?.trim() || + !farm.contactPerson?.lastname?.trim() || + !farm.telephone?.trim() || + !farm.vatID?.trim() || + !farm.address?.adminUnitL1?.trim() || + !farm.address?.adminUnitL2?.trim() || + !farm.address?.addressArea?.trim() || + !farm.address?.municipality?.trim() || + !farm.address?.community?.trim() || + !farm.address?.locatorName?.trim(); return ( <> @@ -141,8 +160,8 @@ const FarmPage = () => { - - + + @@ -195,6 +214,78 @@ const FarmPage = () => { Delete + + + Parcels ({farmParcels.length}) + + {parcelsLoading && ( + + {Array.from({ length: 3 }).map((_, i) => ( + + + + ))} + + )} + {!parcelsLoading && farmParcels.length === 0 && ( + No parcels assigned to this farm. + )} + {!parcelsLoading && farmParcels.length > 0 && ( + + {farmParcels.map(p => { + const parcelId = p["@id"].split(':').pop() ?? ''; + const hasPolygon = !!p.hasGeometry?.asWKT?.trim(); + return ( + + + navigate(`/farm-locations/farm-parcel/${parcelId}`)} + sx={{ height: '100%' }} + > + + + + {p.identifier || 'Unnamed parcel'} + + {hasPolygon && ( + + + + )} + + + {p.category && } + {p.area && } + + + {p.inRegion && ( + + Region: {p.inRegion} + + )} + {p.hasToponym && ( + + Toponym: {p.hasToponym} + + )} + {p.description && ( + + {p.description} + + )} + + + + + + ); + })} + + )} + } From 90db4b31b0465145bc92aa71bba084e156944c68 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Wed, 24 Jun 2026 16:21:02 +0200 Subject: [PATCH 84/91] animal and animal lactating activities added --- .../ActivityDynamicCRUDActions.tsx | 137 ++++++++++++++++++ src/models/FarmCalendarActivities.ts | 44 ++++++ .../FarmCalendarActivities/EditActivity.tsx | 4 + .../RegisterActivity.tsx | 4 + src/utils/activityModelFactory.ts | 57 ++++++++ 5 files changed, 246 insertions(+) diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx index 8576800..ebea10e 100644 --- a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -7,6 +7,7 @@ import { DateTimePicker } from "@mui/x-date-pickers"; import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; import { FarmParcelModel } from "@models/FarmParcel"; import { FarmCropModel } from "@models/FarmCrop"; +import { FarmAnimalModel } from "@models/FarmAnimalModel"; import AddIcon from '@mui/icons-material/Add'; import SaveIcon from '@mui/icons-material/Save'; @@ -79,6 +80,20 @@ const REQUIRED_KEYS_BY_TYPE: Record> = { 'title', 'phenomenonTime', 'madeBySensor.name', 'hasArea', 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', ]), + AnimalActivity: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'hasAnimal', + ]), + AnimalLactatingActivity: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'hasAnimal', + 'hasDaysInMilk', 'hasLactationNumber', 'hasControl', + 'hasTotalMilkYield.hasValue', 'hasTotalMilkYield.unit', + 'hasMilkYield.hasValue', 'hasMilkYield.unit', + 'hasRCS.hasValue', 'hasRCS.unit', + 'hasUrea.hasValue', 'hasUrea.unit', + 'hasFat.hasValue', 'hasFat.unit', + 'hasProtein.hasValue', 'hasProtein.unit', + 'hasDryMatter.hasValue', 'hasDryMatter.unit', + ]), }; const ActivityDynamicCRUDActions = ({ activity, activityTypes, onAdd, onDelete, onSave, loading, canEdit, canDelete }: ActivityDynamicCRUDActionsProps) => { @@ -96,6 +111,7 @@ const ActivityDynamicCRUDActions = ({ activity, act const [parentActivity, setParentActivity] = useState(''); const [severity, setSeverity] = useState(''); const [usesIrrigationSystem, setUsesIrrigationSystem] = useState(''); + const [selectedAnimal, setSelectedAnimal] = useState(''); /** All calendar activities section start */ const [allActivities, setAllActivities] = useState([]); @@ -170,6 +186,16 @@ const ActivityDynamicCRUDActions = ({ activity, act setSelectedAgriCrop(idParts[idParts.length - 1]); } + let animalID: string | undefined; + if ('hasAnimal' in formData) { + if ((formData as any).hasAnimal) + animalID = (formData as any).hasAnimal["@id"]; + } + if (animalID) { + const idParts = animalID.split(':'); + setSelectedAnimal(idParts[idParts.length - 1]); + } + let agriMachinesIDs: string[] | undefined; if ('usesAgriculturalMachinery' in formData) { if (formData.usesAgriculturalMachinery) @@ -558,6 +584,94 @@ const ActivityDynamicCRUDActions = ({ activity, act ) } + const renderSelectedAnimal = () => { + return ( + <> + {'hasAnimal' in formData && ( + + canEdit={canEdit} + endpoint='proxy/farmcalendar/api/v1/FarmAnimals/?format=json' + label='Animal' + selectedValue={selectedAnimal} + setSelectedValue={setSelectedAnimal} + getOptionLabel={item => `${item.name || item.nationalID} - ${item.species} ${item.breed ? '(' + item.breed + ')' : ''}`} + getOptionValue={item => item["@id"].split(':')[3]} + required={isReq('hasAnimal')} + error={isReq('hasAnimal') && !selectedAnimal} + /> + )} + + ) + } + + const renderLactationFields = () => { + if (!('hasDaysInMilk' in formData)) return null; + const measurements: { key: string; label: string }[] = [ + { key: 'hasTotalMilkYield', label: 'Total milk yield' }, + { key: 'hasMilkYield', label: 'Milk yield' }, + { key: 'hasRCS', label: 'RCS' }, + { key: 'hasUrea', label: 'Urea' }, + { key: 'hasFat', label: 'Fat' }, + { key: 'hasProtein', label: 'Protein' }, + { key: 'hasDryMatter', label: 'Dry matter' }, + ]; + return ( + <> + + + + + + {measurements.map(m => ( + + + + + ))} + + ) + } + const renderHasArea = () => { return ( <> @@ -827,6 +941,24 @@ const ActivityDynamicCRUDActions = ({ activity, act 'madeBySensor.name': () => !!((formData as any).madeBySensor?.name ?? '').toString().trim(), 'hasApplicationMethod': () => !!((formData as any).hasApplicationMethod ?? '').toString().trim(), 'severity': () => !!severity, + 'hasAnimal': () => !!selectedAnimal, + 'hasDaysInMilk': () => !!((formData as any).hasDaysInMilk ?? '').toString().trim(), + 'hasLactationNumber': () => !!((formData as any).hasLactationNumber ?? '').toString().trim(), + 'hasControl': () => !!((formData as any).hasControl ?? '').toString().trim(), + 'hasTotalMilkYield.hasValue': () => !!((formData as any).hasTotalMilkYield?.hasValue ?? '').toString().trim(), + 'hasTotalMilkYield.unit': () => !!((formData as any).hasTotalMilkYield?.unit ?? '').toString().trim(), + 'hasMilkYield.hasValue': () => !!((formData as any).hasMilkYield?.hasValue ?? '').toString().trim(), + 'hasMilkYield.unit': () => !!((formData as any).hasMilkYield?.unit ?? '').toString().trim(), + 'hasRCS.hasValue': () => !!((formData as any).hasRCS?.hasValue ?? '').toString().trim(), + 'hasRCS.unit': () => !!((formData as any).hasRCS?.unit ?? '').toString().trim(), + 'hasUrea.hasValue': () => !!((formData as any).hasUrea?.hasValue ?? '').toString().trim(), + 'hasUrea.unit': () => !!((formData as any).hasUrea?.unit ?? '').toString().trim(), + 'hasFat.hasValue': () => !!((formData as any).hasFat?.hasValue ?? '').toString().trim(), + 'hasFat.unit': () => !!((formData as any).hasFat?.unit ?? '').toString().trim(), + 'hasProtein.hasValue': () => !!((formData as any).hasProtein?.hasValue ?? '').toString().trim(), + 'hasProtein.unit': () => !!((formData as any).hasProtein?.unit ?? '').toString().trim(), + 'hasDryMatter.hasValue': () => !!((formData as any).hasDryMatter?.hasValue ?? '').toString().trim(), + 'hasDryMatter.unit': () => !!((formData as any).hasDryMatter?.unit ?? '').toString().trim(), 'hasCompostMaterial': () => { const arr = (formData as any).hasCompostMaterial; if (!Array.isArray(arr) || arr.length === 0) return false; @@ -874,6 +1006,9 @@ const ActivityDynamicCRUDActions = ({ activity, act if ('hasAgriCrop' in body) { (body.hasAgriCrop as { '@id': string })['@id'] = `urn:farmcalendar:FarmCrop:${selectedAgriCrop}`; } + if ('hasAnimal' in body) { + (body.hasAnimal as { '@id': string })['@id'] = `urn:farmcalendar:Animal:${selectedAnimal}`; + } if ('usesAgriculturalMachinery' in body) { (body.usesAgriculturalMachinery as string[]) = selectedAgriMachines; } @@ -975,6 +1110,8 @@ const ActivityDynamicCRUDActions = ({ activity, act {renderSeverity()} {renderSensorResultAndObservedProperty()} {renderSelectedCrop()} + {renderSelectedAnimal()} + {renderLactationFields()} {renderHasArea()} {renderOperatedOnCompostPile()} {renderAppliedAmount()} diff --git a/src/models/FarmCalendarActivities.ts b/src/models/FarmCalendarActivities.ts index 7c296f0..97da6c6 100644 --- a/src/models/FarmCalendarActivities.ts +++ b/src/models/FarmCalendarActivities.ts @@ -417,4 +417,48 @@ export interface YieldPredictionModel extends BaseActivityModel { hasValue: string; }; observedProperty: string; +} + +interface AnimalActivityBase extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + hasAnimal: { + "@id": string; + "@type": string; + }; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; +} + +export interface AnimalActivityModel extends AnimalActivityBase {} + +interface MeasurementValue { + "@id": string; + "@type": string; + unit: string; + hasValue: string; +} + +export interface AnimalLactatingActivityModel extends AnimalActivityBase { + hasDaysInMilk: string; + hasLactationNumber: string; + hasControl: string; + hasTotalMilkYield: MeasurementValue; + hasMilkYield: MeasurementValue; + hasRCS: MeasurementValue; + hasUrea: MeasurementValue; + hasFat: MeasurementValue; + hasProtein: MeasurementValue; + hasDryMatter: MeasurementValue; } \ No newline at end of file diff --git a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx index 6cca7c0..72bfcd7 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx @@ -8,6 +8,8 @@ import { useLocation, useNavigate, useOutletContext, useParams } from "react-rou import { BaseActivityModel, AddRawMaterialOperationModel, + AnimalActivityModel, + AnimalLactatingActivityModel, CompostOperationModel, CompostTurningOperationModel, CropGrowthStageObservationModel, @@ -43,6 +45,8 @@ const ActivityFormComponentMap: { [key: string]: React.FC } = { 'SprayingRecommendation': (props) => {...props} />, 'VigorEstimation': (props) => {...props} />, 'YieldPrediction': (props) => {...props} />, + 'AnimalActivity': (props) => {...props} />, + 'AnimalLactatingActivity': (props) => {...props} />, }; interface LocationState { diff --git a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/RegisterActivity.tsx b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/RegisterActivity.tsx index cdada90..9cb6f37 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/RegisterActivity.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/RegisterActivity.tsx @@ -3,6 +3,8 @@ import { FarmCalendarActivityTypeModel } from "@models/FarmCalendarActivityType" import { BaseActivityModel, AddRawMaterialOperationModel, + AnimalActivityModel, + AnimalLactatingActivityModel, CompostOperationModel, CompostTurningOperationModel, CropGrowthStageObservationModel, @@ -44,6 +46,8 @@ const ActivityFormComponentMap: { [key: string]: React.FC } = { 'SprayingRecommendation': (props) => {...props} />, 'VigorEstimation': (props) => {...props} />, 'YieldPrediction': (props) => {...props} />, + 'AnimalActivity': (props) => {...props} />, + 'AnimalLactatingActivity': (props) => {...props} />, }; interface LocationState { activityTypes: FarmCalendarActivityTypeModel[] | undefined; diff --git a/src/utils/activityModelFactory.ts b/src/utils/activityModelFactory.ts index 722255d..45fbe87 100644 --- a/src/utils/activityModelFactory.ts +++ b/src/utils/activityModelFactory.ts @@ -1,6 +1,8 @@ import { BaseActivityModel, AddRawMaterialOperationModel, + AnimalActivityModel, + AnimalLactatingActivityModel, CompostOperationModel, CompostTurningOperationModel, CropGrowthStageObservationModel, @@ -363,6 +365,59 @@ const getEmptyYieldPrediction = (id: string): YieldPredictionModel => ({ }); +const emptyMeasurement = () => ({ + "@id": "", + "@type": "", + unit: "", + hasValue: "", +}); + +const getEmptyAnimalActivity = (id: string): AnimalActivityModel => ({ + ...getEmptyBaseActivity(id), + "@type": "AnimalActivity", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + hasAgriParcel: { + "@id": "", + "@type": "Parcel" + }, + hasAnimal: { + "@id": "", + "@type": "Animal" + }, + responsibleAgent: null, + usesAgriculturalMachinery: [], + isPartOfActivity: null, +}); + +const getEmptyAnimalLactatingActivity = (id: string): AnimalLactatingActivityModel => ({ + ...getEmptyBaseActivity(id), + "@type": "AnimalLactatingActivity", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + hasAgriParcel: { + "@id": "", + "@type": "Parcel" + }, + hasAnimal: { + "@id": "", + "@type": "Animal" + }, + responsibleAgent: null, + usesAgriculturalMachinery: [], + isPartOfActivity: null, + hasDaysInMilk: "", + hasLactationNumber: "", + hasControl: "", + hasTotalMilkYield: emptyMeasurement(), + hasMilkYield: emptyMeasurement(), + hasRCS: emptyMeasurement(), + hasUrea: emptyMeasurement(), + hasFat: emptyMeasurement(), + hasProtein: emptyMeasurement(), + hasDryMatter: emptyMeasurement(), +}); + export const activityModelFactory: Record BaseActivityModel> = { "/api/v1/YieldPrediction/": getEmptyYieldPrediction, "/api/v1/VigorEstimation/": getEmptyVigorEstimation, @@ -379,4 +434,6 @@ export const activityModelFactory: Record BaseActivityMo "/api/v1/CompostTurningOperations/": getEmptyCompostTurningOperation, "/api/v1/CompostOperations/": getEmptyCompostOperation, "/api/v1/AddRawMaterialOperations/": getEmptyAddRawMaterialOperation, + "/api/v1/AnimalActivities/": getEmptyAnimalActivity, + "/api/v1/AnimalLactatingActivities/": getEmptyAnimalLactatingActivity, }; \ No newline at end of file From ac7f45a97b98940e4ddd78cd2ee8bf7be3701bef Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Wed, 24 Jun 2026 16:32:54 +0200 Subject: [PATCH 85/91] added unit of measurement (celsius) to base GDD --- .../dashboard/services/PestCRUDActions/PestCRUDActions.tsx | 2 +- src/pages/dashboard/services/PestAndDisease/Pests.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.tsx b/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.tsx index d3e75e7..475bb45 100644 --- a/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.tsx +++ b/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.tsx @@ -188,7 +188,7 @@ const PestCRUDActions: React.FC = ({ pest, onAction, canEd - + diff --git a/src/pages/dashboard/services/PestAndDisease/Pests.tsx b/src/pages/dashboard/services/PestAndDisease/Pests.tsx index 7dcc816..1b5948d 100644 --- a/src/pages/dashboard/services/PestAndDisease/Pests.tsx +++ b/src/pages/dashboard/services/PestAndDisease/Pests.tsx @@ -68,7 +68,7 @@ const PestsPage = () => { const pestsHeadCells: readonly HeadCell[] = [ { id: 'name', numeric: false, label: 'Name' }, { id: 'eppoCode', numeric: false, label: 'EPPO code' }, - { id: 'baseGDD', numeric: false, label: 'Base GDD' }, + { id: 'baseGDD', numeric: false, label: 'Base GDD ยฐC' }, ]; const handleRowClick = (pest: PestRow) => { From 15a17ebc42d27cf5a32433a33d317a479d787d48 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Wed, 24 Jun 2026 16:35:11 +0200 Subject: [PATCH 86/91] multi select chips have remove button --- .../shared/GenericSelect/GenericSelect.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/shared/GenericSelect/GenericSelect.tsx b/src/components/shared/GenericSelect/GenericSelect.tsx index 5a3efcb..ca199e7 100644 --- a/src/components/shared/GenericSelect/GenericSelect.tsx +++ b/src/components/shared/GenericSelect/GenericSelect.tsx @@ -75,7 +75,18 @@ const GenericSelect = ({ ? items.find(i => getOptionValue(i) === value) : null; const itemLabel = item ? getOptionLabel(item) : value; - return ; + return ( + e.stopPropagation() : undefined} + onDelete={canEdit ? () => { + (setSelectedValue as React.Dispatch>)(prev => + (prev ?? []).filter(v => v !== value) + ); + } : undefined} + /> + ); })} ) : undefined} From e810e22def2e378370d67f82e8325334cdbc5b28 Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 30 Jun 2026 12:09:17 +0200 Subject: [PATCH 87/91] toponym, region, and area now mandatory fields --- .../FarmLocations/AddFarmParcel/AddFarmParcel.tsx | 12 +++++++++--- .../FarmCalendar/FarmLocations/FarmParcel.tsx | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx index f0ea550..ae3218d 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx @@ -19,6 +19,9 @@ const REQUIRED_KEYS = new Set([ 'location.long', 'validFrom', 'validTo', + 'inRegion', + 'hasToponym', + 'area', ]); const isReq = (key: string) => REQUIRED_KEYS.has(key); @@ -148,6 +151,9 @@ const AddFarmParcel: React.FC = ({ onAction }) => { case 'location.long': return formData.location.long == null || Number.isNaN(formData.location.long); case 'validFrom': return !formData.validFrom; case 'validTo': return !formData.validTo; + case 'inRegion': return !formData.inRegion?.trim(); + case 'hasToponym': return !formData.hasToponym?.trim(); + case 'area': return formData.area == null || formData.area === '' || Number.isNaN(formData.area as any); default: return false; } }; @@ -179,8 +185,8 @@ const AddFarmParcel: React.FC = ({ onAction }) => { - - + + } label="Nitro area" /> @@ -191,7 +197,7 @@ const AddFarmParcel: React.FC = ({ onAction }) => { } label="Ground slope" /> - + diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcel.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcel.tsx index c6338ed..18314f2 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcel.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcel.tsx @@ -24,6 +24,9 @@ const REQUIRED_KEYS = new Set([ 'location.long', 'validFrom', 'validTo', + 'inRegion', + 'hasToponym', + 'area', ]); const isReq = (key: string) => REQUIRED_KEYS.has(key); @@ -166,6 +169,9 @@ const FarmParcelPage = () => { case 'location.long': return parcel.location.long == null || Number.isNaN(parcel.location.long); case 'validFrom': return !parcel.validFrom; case 'validTo': return !parcel.validTo; + case 'inRegion': return !parcel.inRegion?.trim(); + case 'hasToponym': return !parcel.hasToponym?.trim(); + case 'area': return parcel.area == null || parcel.area === '' || Number.isNaN(parcel.area as any); default: return false; } }; @@ -203,8 +209,8 @@ const FarmParcelPage = () => { - - + + {}} />} label="Nitro area" /> @@ -215,7 +221,7 @@ const FarmParcelPage = () => { {}} />} label="Ground slope" /> - + From ecb549f6b2d43443b31a3172d35e9fe666e7910a Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Tue, 30 Jun 2026 12:14:23 +0200 Subject: [PATCH 88/91] added unit of measurement to certain pest fields and formatted them --- .../dashboard/services/PestCRUDActions/PestCRUDActions.tsx | 4 ++-- src/pages/dashboard/services/PestAndDisease/Pests.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.tsx b/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.tsx index 475bb45..1180003 100644 --- a/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.tsx +++ b/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.tsx @@ -204,8 +204,8 @@ const PestCRUDActions: React.FC = ({ pest, onAction, canEd - handleGddPointChange(index, e)} disabled={index > 0 || formData.gdd_points.length > 1} error={isNaN(gddp.start) || gddp.start < 0} /> - handleGddPointChange(index, e)} disabled={!isLastPoint} error={isNaN(gddp.end) || isEndValueError} /> + handleGddPointChange(index, e)} disabled={index > 0 || formData.gdd_points.length > 1} error={isNaN(gddp.start) || gddp.start < 0} /> + handleGddPointChange(index, e)} disabled={!isLastPoint} error={isNaN(gddp.end) || isEndValueError} /> handleGddPointChange(index, e)} sx={{ flexGrow: 1 }} error={!gddp.descriptor?.trim()} /> diff --git a/src/pages/dashboard/services/PestAndDisease/Pests.tsx b/src/pages/dashboard/services/PestAndDisease/Pests.tsx index 1b5948d..39cfe33 100644 --- a/src/pages/dashboard/services/PestAndDisease/Pests.tsx +++ b/src/pages/dashboard/services/PestAndDisease/Pests.tsx @@ -68,7 +68,7 @@ const PestsPage = () => { const pestsHeadCells: readonly HeadCell[] = [ { id: 'name', numeric: false, label: 'Name' }, { id: 'eppoCode', numeric: false, label: 'EPPO code' }, - { id: 'baseGDD', numeric: false, label: 'Base GDD ยฐC' }, + { id: 'baseGDD', numeric: false, label: 'Base GDD (ยฐC)' }, ]; const handleRowClick = (pest: PestRow) => { From 097e0c7136af04640fd1a418a253a8be54aa7eee Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Wed, 8 Jul 2026 12:28:05 +0200 Subject: [PATCH 89/91] implemented soild types and crop types to irrigation management and made changes to places where they were previously being used --- src/App.tsx | 12 ++ src/hooks/useFetch.ts | 10 +- src/main.tsx | 20 +++ src/models/CropType.ts | 7 + src/models/SoilType.ts | 6 + .../Irrigation/AddCropType/AddCropType.tsx | 135 +++++++++++++++ .../AddCropType/AddCropType.types.ts | 4 + .../Irrigation/AddSoilType/AddSoilType.tsx | 122 ++++++++++++++ .../AddSoilType/AddSoilType.types.ts | 4 + .../services/Irrigation/CropTypes.tsx | 152 +++++++++++++++++ .../services/Irrigation/EToCalculator.tsx | 23 +-- .../services/Irrigation/EditCropType.tsx | 159 ++++++++++++++++++ .../services/Irrigation/EditSoilType.tsx | 148 ++++++++++++++++ .../Irrigation/SoilMoistureAnalysis.tsx | 7 +- .../services/Irrigation/SoilTypes.tsx | 149 ++++++++++++++++ vite.config.ts | 2 +- 16 files changed, 943 insertions(+), 17 deletions(-) create mode 100644 src/models/CropType.ts create mode 100644 src/models/SoilType.ts create mode 100644 src/pages/dashboard/services/Irrigation/AddCropType/AddCropType.tsx create mode 100644 src/pages/dashboard/services/Irrigation/AddCropType/AddCropType.types.ts create mode 100644 src/pages/dashboard/services/Irrigation/AddSoilType/AddSoilType.tsx create mode 100644 src/pages/dashboard/services/Irrigation/AddSoilType/AddSoilType.types.ts create mode 100644 src/pages/dashboard/services/Irrigation/CropTypes.tsx create mode 100644 src/pages/dashboard/services/Irrigation/EditCropType.tsx create mode 100644 src/pages/dashboard/services/Irrigation/EditSoilType.tsx create mode 100644 src/pages/dashboard/services/Irrigation/SoilTypes.tsx diff --git a/src/App.tsx b/src/App.tsx index 73f8231..919fac6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -144,6 +144,18 @@ export default function App() { icon: , disabled: !hasAccess('IRM'), }, + { + segment: 'crop-types', + title: 'Crop types', + icon: , + disabled: !hasAccess('IRM'), + }, + { + segment: 'soil-types', + title: 'Soil types', + icon: , + disabled: !hasAccess('IRM'), + }, { kind: 'header', title: 'Pest and disease management', diff --git a/src/hooks/useFetch.ts b/src/hooks/useFetch.ts index 46cdff1..dfe82da 100644 --- a/src/hooks/useFetch.ts +++ b/src/hooks/useFetch.ts @@ -6,6 +6,8 @@ interface FetchOptions { headers?: Record; body?: any; responseType?: 'json' | 'blob'; + url?: string; + noCache?: boolean; } const useFetch = ( @@ -50,16 +52,18 @@ const useFetch = ( finalHeaders["Authorization"] = `Bearer ${token}`; } - return { + const opts: RequestInit = { method: currentOptions.method, headers: finalHeaders, body: finalBody, }; + if (currentOptions.noCache) opts.cache = 'no-store'; + return opts; }; const initialToken = session?.user?.token; let fetchOptions = getFetchOptions(initialToken); - let response = await fetch(apiUrl + url, fetchOptions); + let response = await fetch(apiUrl + (currentOptions.url ?? url), fetchOptions); if (response.status === 401) { const refreshToken = session?.user?.refresh_token; @@ -93,7 +97,7 @@ const useFetch = ( }); fetchOptions = getFetchOptions(newToken.access); - response = await fetch(apiUrl + url, fetchOptions); + response = await fetch(apiUrl + (currentOptions.url ?? url), fetchOptions); } if (!response.ok) { diff --git a/src/main.tsx b/src/main.tsx index 7b3e9a3..4d4f1ed 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -30,6 +30,10 @@ import WeatherDataPage from '@pages/dashboard/services/WeatherData/WeatherData.t import EToCalculatorPage from '@pages/dashboard/services/Irrigation/EToCalculator.tsx'; import UploadDatasetPage from '@pages/dashboard/services/Irrigation/UploadDataset.tsx'; import SoilMoistureAnalysisPage from '@pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx'; +import CropTypesPage from '@pages/dashboard/services/Irrigation/CropTypes.tsx'; +import EditCropTypePage from '@pages/dashboard/services/Irrigation/EditCropType.tsx'; +import SoilTypesPage from '@pages/dashboard/services/Irrigation/SoilTypes.tsx'; +import EditSoilTypePage from '@pages/dashboard/services/Irrigation/EditSoilType.tsx'; import IrrigationOperationsReportPage from '@pages/dashboard/services/FarmCalendar/ReportingService/IrrigationOperations.tsx'; import FarmAnimalsReportPage from '@pages/dashboard/services/FarmCalendar/ReportingService/FarmAnimals.tsx'; import PestsPage from '@pages/dashboard/services/PestAndDisease/Pests.tsx'; @@ -151,6 +155,22 @@ const router = createBrowserRouter([ path: 'soil-moisture-analysis', Component: SoilMoistureAnalysisPage }, + { + path: 'crop-types', + Component: CropTypesPage + }, + { + path: 'crop-types/:id', + Component: EditCropTypePage + }, + { + path: 'soil-types', + Component: SoilTypesPage + }, + { + path: 'soil-types/:id', + Component: EditSoilTypePage + }, ] }, { diff --git a/src/models/CropType.ts b/src/models/CropType.ts new file mode 100644 index 0000000..7c5bdf9 --- /dev/null +++ b/src/models/CropType.ts @@ -0,0 +1,7 @@ +export interface CropTypeModel { + id: string; + crop: string; + kc_init: number; + kc_mid: number; + kc_end: number; +} diff --git a/src/models/SoilType.ts b/src/models/SoilType.ts new file mode 100644 index 0000000..835a4d0 --- /dev/null +++ b/src/models/SoilType.ts @@ -0,0 +1,6 @@ +export interface SoilTypeModel { + id: string; + soil_type: string; + field_capacity: number; + wilting_point: number; +} diff --git a/src/pages/dashboard/services/Irrigation/AddCropType/AddCropType.tsx b/src/pages/dashboard/services/Irrigation/AddCropType/AddCropType.tsx new file mode 100644 index 0000000..31254a5 --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/AddCropType/AddCropType.tsx @@ -0,0 +1,135 @@ +import { Box, Button, Stack, TextField } from "@mui/material"; +import { useEffect, useState } from "react"; +import AddIcon from '@mui/icons-material/Add'; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import { CropTypeModel } from "@models/CropType"; +import { AddCropTypeProps } from "./AddCropType.types"; + +const REQUIRED_KEYS = new Set(['crop', 'kc_init', 'kc_mid', 'kc_end']); +const isReq = (k: string) => REQUIRED_KEYS.has(k); + +const KC_MIN = 0; +const KC_MAX = 2; + +const inKcRange = (v: number | null) => + v != null && !Number.isNaN(v) && v > KC_MIN && v < KC_MAX; + +type CropCreate = Omit; + +const emptyForm: CropCreate = { + crop: '', + kc_init: NaN, + kc_mid: NaN, + kc_end: NaN, +}; + +const AddCropType: React.FC = ({ onAction, existingNames = [] }) => { + const [formData, setFormData] = useState({ ...emptyForm }); + + const { fetchData, response, error, loading } = useFetch( + 'proxy/irrigation/api/v1/eto/crop-types/', + { method: 'POST' } + ); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + + useEffect(() => { + if (response) { + showSnackbar('success', 'Crop type added successfully'); + setFormData({ ...emptyForm }); + onAction && onAction(); + } + }, [response]); + + useEffect(() => { + if (error) showSnackbar('error', 'An error occurred'); + }, [error]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: name === 'crop' ? value : (value === '' ? NaN : parseFloat(value)), + })); + }; + + const isDuplicate = existingNames.includes(formData.crop.trim()); + + const fieldEmpty = (key: string) => { + switch (key) { + case 'crop': return !formData.crop.trim(); + case 'kc_init': return !inKcRange(formData.kc_init); + case 'kc_mid': return !inKcRange(formData.kc_mid); + case 'kc_end': return !inKcRange(formData.kc_end); + default: return false; + } + }; + + const isFormInvalid = Array.from(REQUIRED_KEYS).some(k => fieldEmpty(k)) || isDuplicate; + + const handlePost = () => { + fetchData({ body: { ...formData, crop: formData.crop.trim() } }); + }; + + return ( + + + + + + + + + + + + + + + ); +}; + +export default AddCropType; diff --git a/src/pages/dashboard/services/Irrigation/AddCropType/AddCropType.types.ts b/src/pages/dashboard/services/Irrigation/AddCropType/AddCropType.types.ts new file mode 100644 index 0000000..3077cbe --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/AddCropType/AddCropType.types.ts @@ -0,0 +1,4 @@ +export interface AddCropTypeProps { + onAction?: () => void; + existingNames?: string[]; +} diff --git a/src/pages/dashboard/services/Irrigation/AddSoilType/AddSoilType.tsx b/src/pages/dashboard/services/Irrigation/AddSoilType/AddSoilType.tsx new file mode 100644 index 0000000..0127b98 --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/AddSoilType/AddSoilType.tsx @@ -0,0 +1,122 @@ +import { Box, Button, Stack, TextField } from "@mui/material"; +import { useEffect, useState } from "react"; +import AddIcon from '@mui/icons-material/Add'; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import { SoilTypeModel } from "@models/SoilType"; +import { AddSoilTypeProps } from "./AddSoilType.types"; + +const REQUIRED_KEYS = new Set(['soil_type', 'field_capacity', 'wilting_point']); +const isReq = (k: string) => REQUIRED_KEYS.has(k); + +const inUnitRange = (v: number | null) => + v != null && !Number.isNaN(v) && v > 0 && v < 1; + +type SoilCreate = Omit; + +const emptyForm: SoilCreate = { + soil_type: '', + field_capacity: NaN, + wilting_point: NaN, +}; + +const AddSoilType: React.FC = ({ onAction, existingNames = [] }) => { + const [formData, setFormData] = useState({ ...emptyForm }); + + const { fetchData, response, error, loading } = useFetch( + 'proxy/irrigation/api/v1/dataset/soil-types/', + { method: 'POST' } + ); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + + useEffect(() => { + if (response) { + showSnackbar('success', 'Soil type added successfully'); + setFormData({ ...emptyForm }); + onAction && onAction(); + } + }, [response]); + + useEffect(() => { + if (error) showSnackbar('error', 'An error occurred'); + }, [error]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: name === 'soil_type' ? value : (value === '' ? NaN : parseFloat(value)), + })); + }; + + const isDuplicate = existingNames.includes(formData.soil_type.trim()); + + const fieldEmpty = (key: string) => { + switch (key) { + case 'soil_type': return !formData.soil_type.trim(); + case 'field_capacity': return !inUnitRange(formData.field_capacity); + case 'wilting_point': return !inUnitRange(formData.wilting_point); + default: return false; + } + }; + + const isFormInvalid = Array.from(REQUIRED_KEYS).some(k => fieldEmpty(k)) || isDuplicate; + + const handlePost = () => { + fetchData({ body: { ...formData, soil_type: formData.soil_type.trim() } }); + }; + + return ( + + + + + + + + + + + + + + ); +}; + +export default AddSoilType; diff --git a/src/pages/dashboard/services/Irrigation/AddSoilType/AddSoilType.types.ts b/src/pages/dashboard/services/Irrigation/AddSoilType/AddSoilType.types.ts new file mode 100644 index 0000000..eacdfc5 --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/AddSoilType/AddSoilType.types.ts @@ -0,0 +1,4 @@ +export interface AddSoilTypeProps { + onAction?: () => void; + existingNames?: string[]; +} diff --git a/src/pages/dashboard/services/Irrigation/CropTypes.tsx b/src/pages/dashboard/services/Irrigation/CropTypes.tsx new file mode 100644 index 0000000..be6f1e8 --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/CropTypes.tsx @@ -0,0 +1,152 @@ +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import GenericSortableTable from "@components/shared/GenericSortableTable/GenericSortableTable"; +import { HeadCell } from "@components/shared/GenericSortableTable/GenericSortableTable.types"; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import { Accordion, AccordionDetails, AccordionSummary, Box, IconButton, Skeleton, Stack, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useNavigate, useOutletContext } from "react-router-dom"; +import AddCropType from "./AddCropType/AddCropType"; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { ServiceContextType } from "@layouts/services/IrrigationManagementLayout"; +import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; +import useDialog from "@hooks/useDialog"; +import { CropTypeModel } from "@models/CropType"; + +interface CropRow { + id: string; + crop: string; + kc_init: number; + kc_mid: number; + kc_end: number; + actions: string; +} + +const CropTypesPage = () => { + const { actions } = useOutletContext(); + const canAdd = actions.includes('add'); + const canEdit = actions.includes('edit'); + const canDelete = actions.includes('delete'); + + const [rows, setRows] = useState([]); + const [pendingDelete, setPendingDelete] = useState<{ id: string; label: string } | null>(null); + const [expanded, setExpanded] = useState(false); + + const { fetchData, loading, response, error } = useFetch( + 'proxy/irrigation/api/v1/eto/option-types/', + { method: 'GET', noCache: true } + ); + + const { fetchData: deleteFetchData, response: deleteResponse, error: deleteError } = useFetch( + '', + { method: 'DELETE' } + ); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const { dialogProps, showDialog } = useDialog(); + + useEffect(() => { fetchData(); }, []); + + useEffect(() => { + if (error) showSnackbar('error', 'Error loading crop types'); + }, [error]); + + useEffect(() => { + if (deleteError) showSnackbar('error', 'Error deleting crop type'); + }, [deleteError]); + + useEffect(() => { + if (deleteResponse) { + showSnackbar('success', 'Crop type deleted'); + fetchData(); + } + }, [deleteResponse]); + + useEffect(() => { + if (response) { + setRows(response.map(c => ({ + id: c.id, + crop: c.crop, + kc_init: c.kc_init, + kc_mid: c.kc_mid, + kc_end: c.kc_end, + actions: '', + }))); + } + }, [response]); + + const navigate = useNavigate(); + + const headCells: readonly HeadCell[] = [ + { id: 'crop', numeric: false, label: 'Crop name' }, + { id: 'kc_init', numeric: true, label: 'Kc init' }, + { id: 'kc_mid', numeric: true, label: 'Kc mid' }, + { id: 'kc_end', numeric: true, label: 'Kc end' }, + { id: 'actions', numeric: false, label: 'Actions', disableSort: true, renderCell: (row) => ( + + { e.stopPropagation(); navigate(`../crop-types/${row.id}`); }} + > + + + { + e.stopPropagation(); + setPendingDelete({ id: row.id, label: row.crop }); + showDialog({ + title: `Delete crop type "${row.crop}"?`, + variant: 'yes-no', + children: <>, + }); + }} + > + + + + ) }, + ]; + + const handleDelete = async () => { + if (!pendingDelete) return; + await deleteFetchData({ url: `proxy/irrigation/api/v1/eto/crop-types/${pendingDelete.id}/` }); + setPendingDelete(null); + }; + + const onAddNew = () => { + fetchData(); + setExpanded(false); + }; + + return ( + + setExpanded(v => !v)}> + }> + Add new crop type + + + r.crop)} /> + + + {loading && } + {!(loading) && !error && ( + + )} + + + + ); +}; + +export default CropTypesPage; diff --git a/src/pages/dashboard/services/Irrigation/EToCalculator.tsx b/src/pages/dashboard/services/Irrigation/EToCalculator.tsx index bbc272c..a7cefb0 100644 --- a/src/pages/dashboard/services/Irrigation/EToCalculator.tsx +++ b/src/pages/dashboard/services/Irrigation/EToCalculator.tsx @@ -15,6 +15,7 @@ import CalculateIcon from '@mui/icons-material/Calculate'; import ParcelSelectionModule from "@components/dashboard/ParcelSelectionModule/ParcelSelectionModule"; import { useSession } from "@contexts/SessionContext"; import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; +import { CropTypeModel } from "@models/CropType"; const EToCalculatorPage = () => { @@ -79,25 +80,27 @@ const EToCalculatorPage = () => { Select a location and a time frame to view its ETo calculation. Additionally select the crop type and growth stage for finer tuning. - + endpoint='proxy/irrigation/api/v1/eto/option-types/' method="GET" label='Crop type' selectedValue={selectedCropType} - transformResponse={response => response.crops} setSelectedValue={setSelectedCropType} - getOptionLabel={item => item} - getOptionValue={item => item} + getOptionLabel={item => item.crop} + getOptionValue={item => item.id} /> - - endpoint='proxy/irrigation/api/v1/eto/option-types/' - method="GET" + + endpoint='' + data={[ + { value: 'KC_INIT', label: 'Init' }, + { value: 'KC_MID', label: 'Mid' }, + { value: 'KC_END', label: 'End' }, + ]} label='Stages' selectedValue={selectedCropStage} - transformResponse={response => response.stages} setSelectedValue={setSelectedCropStage} - getOptionLabel={item => item} - getOptionValue={item => item} + getOptionLabel={item => item.label} + getOptionValue={item => item.value} /> (['crop', 'kc_init', 'kc_mid', 'kc_end']); +const isReq = (k: string) => REQUIRED_KEYS.has(k); + +const KC_MIN = 0; +const KC_MAX = 2; +const inKcRange = (v: number | null | undefined) => + v != null && !Number.isNaN(v) && v > KC_MIN && v < KC_MAX; + +const EditCropTypePage = () => { + const { actions } = useOutletContext(); + const canEdit = actions.includes('edit'); + + const { id } = useParams(); + const cropId = id ?? ''; + + const [formData, setFormData] = useState(); + + const { fetchData, loading, response, error } = useFetch( + `proxy/irrigation/api/v1/eto/crop-types/${cropId}/`, + { method: 'GET', noCache: true } + ); + + const { fetchData: putFetchData, response: putResponse, error: putError, loading: putLoading } = useFetch( + `proxy/irrigation/api/v1/eto/crop-types/${cropId}/`, + { method: 'PUT' } + ); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const navigate = useNavigate(); + + useEffect(() => { fetchData(); }, []); + + useEffect(() => { + if (error) showSnackbar('error', 'Error loading crop type'); + }, [error]); + + useEffect(() => { + if (putError) showSnackbar('error', 'Error saving crop type'); + }, [putError]); + + useEffect(() => { + if (response) setFormData(response); + }, [response]); + + useEffect(() => { + if (putResponse) navigate('/crop-types'); + }, [putResponse]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => { + if (!prev) return undefined; + return { + ...prev, + [name]: name === 'crop' ? value : (value === '' ? NaN : parseFloat(value)), + }; + }); + }; + + const fieldEmpty = (key: string) => { + if (!formData) return true; + switch (key) { + case 'crop': return !formData.crop.trim() || formData.crop.trim().length > 64; + case 'kc_init': return !inKcRange(formData.kc_init); + case 'kc_mid': return !inKcRange(formData.kc_mid); + case 'kc_end': return !inKcRange(formData.kc_end); + default: return false; + } + }; + + const isFormInvalid = Array.from(REQUIRED_KEYS).some(k => fieldEmpty(k)); + + const handleSave = () => { + if (!formData) return; + const { id: _id, ...rest } = formData; + putFetchData({ body: { ...rest, crop: rest.crop.trim() } }); + }; + + return ( + <> + {loading && } + {!(loading || error) && formData && ( + + {formData.crop} + + + + 64 ? 'Max 64 characters' : ''} + /> + + + + + + + + + + + + + )} + + + ); +}; + +export default EditCropTypePage; diff --git a/src/pages/dashboard/services/Irrigation/EditSoilType.tsx b/src/pages/dashboard/services/Irrigation/EditSoilType.tsx new file mode 100644 index 0000000..8b6c6a0 --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/EditSoilType.tsx @@ -0,0 +1,148 @@ +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import { SoilTypeModel } from "@models/SoilType"; +import { Box, Button, Card, CardContent, Skeleton, Stack, TextField, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useNavigate, useOutletContext, useParams } from "react-router-dom"; +import SaveIcon from '@mui/icons-material/Save'; +import { ServiceContextType } from "@layouts/services/IrrigationManagementLayout"; + +const REQUIRED_KEYS = new Set(['soil_type', 'field_capacity', 'wilting_point']); +const isReq = (k: string) => REQUIRED_KEYS.has(k); + +const inUnitRange = (v: number | null | undefined) => + v != null && !Number.isNaN(v) && v > 0 && v < 1; + +const EditSoilTypePage = () => { + const { actions } = useOutletContext(); + const canEdit = actions.includes('edit'); + + const { id } = useParams(); + const soilId = id ?? ''; + + const [formData, setFormData] = useState(); + + const { fetchData, loading, response, error } = useFetch( + `proxy/irrigation/api/v1/dataset/soil-types/${soilId}/`, + { method: 'GET', noCache: true } + ); + + const { fetchData: putFetchData, response: putResponse, error: putError, loading: putLoading } = useFetch( + `proxy/irrigation/api/v1/dataset/soil-types/${soilId}/`, + { method: 'PUT' } + ); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const navigate = useNavigate(); + + useEffect(() => { fetchData(); }, []); + + useEffect(() => { + if (error) showSnackbar('error', 'Error loading soil type'); + }, [error]); + + useEffect(() => { + if (putError) showSnackbar('error', 'Error saving soil type'); + }, [putError]); + + useEffect(() => { + if (response) setFormData(response); + }, [response]); + + useEffect(() => { + if (putResponse) navigate('/soil-types'); + }, [putResponse]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => { + if (!prev) return undefined; + return { + ...prev, + [name]: name === 'soil_type' ? value : (value === '' ? NaN : parseFloat(value)), + }; + }); + }; + + const fieldEmpty = (key: string) => { + if (!formData) return true; + switch (key) { + case 'soil_type': return !formData.soil_type.trim() || formData.soil_type.trim().length > 64; + case 'field_capacity': return !inUnitRange(formData.field_capacity); + case 'wilting_point': return !inUnitRange(formData.wilting_point); + default: return false; + } + }; + + const isFormInvalid = Array.from(REQUIRED_KEYS).some(k => fieldEmpty(k)); + + const handleSave = () => { + if (!formData) return; + const { id: _id, ...rest } = formData; + putFetchData({ body: { ...rest, soil_type: rest.soil_type.trim() } }); + }; + + return ( + <> + {loading && } + {!(loading || error) && formData && ( + + {formData.soil_type} + + + + 64 ? 'Max 64 characters' : ''} + /> + + + + + + + + + + + + )} + + + ); +}; + +export default EditSoilTypePage; diff --git a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx index 0e704f7..9cb4b65 100644 --- a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx +++ b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx @@ -8,6 +8,7 @@ import { import * as Highcharts from 'highcharts'; import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; +import { SoilTypeModel } from "@models/SoilType"; import useFetch from "@hooks/useFetch"; import { DatasetResponse, DatasetRow } from "@models/SoilMoisture"; import { Box, Button, Card, CardContent, Skeleton, Typography } from "@mui/material"; @@ -206,14 +207,14 @@ const SoilMoistureAnalysisPage = () => { Delete - + endpoint='proxy/irrigation/api/v1/dataset/soil-types/' method="GET" label='Soil type' selectedValue={selectedSoil} setSelectedValue={setSelectedSoil} - getOptionLabel={item => item} - getOptionValue={item => item} + getOptionLabel={item => item.soil_type} + getOptionValue={item => item.id} /> diff --git a/src/pages/dashboard/services/Irrigation/SoilTypes.tsx b/src/pages/dashboard/services/Irrigation/SoilTypes.tsx new file mode 100644 index 0000000..628f5a3 --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/SoilTypes.tsx @@ -0,0 +1,149 @@ +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import GenericSortableTable from "@components/shared/GenericSortableTable/GenericSortableTable"; +import { HeadCell } from "@components/shared/GenericSortableTable/GenericSortableTable.types"; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import { Accordion, AccordionDetails, AccordionSummary, Box, IconButton, Skeleton, Stack, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useNavigate, useOutletContext } from "react-router-dom"; +import AddSoilType from "./AddSoilType/AddSoilType"; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { ServiceContextType } from "@layouts/services/IrrigationManagementLayout"; +import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; +import useDialog from "@hooks/useDialog"; +import { SoilTypeModel } from "@models/SoilType"; + +interface SoilRow { + id: string; + soil_type: string; + field_capacity: number; + wilting_point: number; + actions: string; +} + +const SoilTypesPage = () => { + const { actions } = useOutletContext(); + const canAdd = actions.includes('add'); + const canEdit = actions.includes('edit'); + const canDelete = actions.includes('delete'); + + const [rows, setRows] = useState([]); + const [pendingDelete, setPendingDelete] = useState<{ id: string; label: string } | null>(null); + const [expanded, setExpanded] = useState(false); + + const { fetchData, loading, response, error } = useFetch( + 'proxy/irrigation/api/v1/dataset/soil-types/', + { method: 'GET', noCache: true } + ); + + const { fetchData: deleteFetchData, response: deleteResponse, error: deleteError } = useFetch( + '', + { method: 'DELETE' } + ); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const { dialogProps, showDialog } = useDialog(); + + useEffect(() => { fetchData(); }, []); + + useEffect(() => { + if (error) showSnackbar('error', 'Error loading soil types'); + }, [error]); + + useEffect(() => { + if (deleteError) showSnackbar('error', 'Error deleting soil type'); + }, [deleteError]); + + useEffect(() => { + if (deleteResponse) { + showSnackbar('success', 'Soil type deleted'); + fetchData(); + } + }, [deleteResponse]); + + useEffect(() => { + if (response) { + setRows(response.map(s => ({ + id: s.id, + soil_type: s.soil_type, + field_capacity: s.field_capacity, + wilting_point: s.wilting_point, + actions: '', + }))); + } + }, [response]); + + const navigate = useNavigate(); + + const headCells: readonly HeadCell[] = [ + { id: 'soil_type', numeric: false, label: 'Soil type' }, + { id: 'field_capacity', numeric: true, label: 'Field capacity' }, + { id: 'wilting_point', numeric: true, label: 'Wilting point' }, + { id: 'actions', numeric: false, label: 'Actions', disableSort: true, renderCell: (row) => ( + + { e.stopPropagation(); navigate(`../soil-types/${row.id}`); }} + > + + + { + e.stopPropagation(); + setPendingDelete({ id: row.id, label: row.soil_type }); + showDialog({ + title: `Delete soil type "${row.soil_type}"?`, + variant: 'yes-no', + children: <>, + }); + }} + > + + + + ) }, + ]; + + const handleDelete = async () => { + if (!pendingDelete) return; + await deleteFetchData({ url: `proxy/irrigation/api/v1/dataset/soil-types/${pendingDelete.id}/` }); + setPendingDelete(null); + }; + + const onAddNew = () => { + fetchData(); + setExpanded(false); + }; + + return ( + + setExpanded(v => !v)}> + }> + Add new soil type + + + r.soil_type)} /> + + + {loading && } + {!(loading) && !error && ( + + )} + + + + ); +}; + +export default SoilTypesPage; diff --git a/vite.config.ts b/vite.config.ts index 89406a7..6e8e9ea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -67,7 +67,7 @@ export default defineConfig({ { urlPattern: ({ request }) => request.method === 'GET' && - /\/proxy\/(farmcalendar\/api\/v1\/FarmCalendarActivityTypes\/|pdm\/api\/v1\/(crop|disease|model|threat-model)\/?|irrigation\/api\/v1\/(dataset\/soil-types|eto\/option-types)\/?)/.test(request.url), + /\/proxy\/(farmcalendar\/api\/v1\/FarmCalendarActivityTypes\/|pdm\/api\/v1\/(crop|disease|model|threat-model)\/?)/.test(request.url), handler: 'StaleWhileRevalidate', options: { cacheName: 'oa-reference', From 0b91051dcbe6c8b791077bf38664e6d60ffd3dbf Mon Sep 17 00:00:00 2001 From: Mateo Budanovic Date: Wed, 8 Jul 2026 14:41:00 +0200 Subject: [PATCH 90/91] parcel selection module redesign, installed react window --- package-lock.json | 34 +++ package.json | 2 + .../ParcelSelectionList.tsx | 220 ++++++++++++++++-- .../ParcelSelectionList.types.ts | 3 +- .../ParcelSelectionModule.tsx | 24 +- 5 files changed, 257 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4b965c4..95877bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@mui/x-charts": "^8.11.1", "@mui/x-date-pickers": "^8.10.2", "@toolpad/core": "^0.16.0", + "@types/react-window": "^1.8.8", "dayjs": "^1.11.19", "dayjs-plugin-utc": "^0.1.2", "highcharts": "^12.4.0", @@ -31,6 +32,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.6.3", + "react-window": "^1.8.11", "vite-tsconfig-paths": "^5.1.4" }, "devDependencies": { @@ -3754,6 +3756,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -7248,6 +7259,12 @@ "node": ">= 0.4" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -8132,6 +8149,23 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", diff --git a/package.json b/package.json index 7733f4b..c460468 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@mui/x-charts": "^8.11.1", "@mui/x-date-pickers": "^8.10.2", "@toolpad/core": "^0.16.0", + "@types/react-window": "^1.8.8", "dayjs": "^1.11.19", "dayjs-plugin-utc": "^0.1.2", "highcharts": "^12.4.0", @@ -34,6 +35,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.6.3", + "react-window": "^1.8.11", "vite-tsconfig-paths": "^5.1.4" }, "devDependencies": { diff --git a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx index f79670c..1f3b2bf 100644 --- a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx +++ b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx @@ -1,58 +1,234 @@ -import { alpha, Box, Typography, useTheme } from "@mui/material"; +import { alpha, Box, IconButton, InputAdornment, TextField, Typography, useMediaQuery, useTheme } from "@mui/material"; +import SearchIcon from '@mui/icons-material/Search'; +import ClearIcon from '@mui/icons-material/Clear'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import { useEffect, useMemo, useRef, useState } from "react"; +import { VariableSizeList, ListChildComponentProps } from 'react-window'; +import { FarmParcelModel } from "@models/FarmParcel"; import { ParcelSelectionListProps } from "./ParcelSelectionList.types"; import placeholder from '/parcel-placeholder.png'; -const ParcelSelectionList: React.FC = ({ parcels, selectedParcelId, f }) => { +interface FarmHeaderItem { + type: 'farm-header'; + farmId: string; + farmName: string; + parcelCount: number; + collapsed: boolean; +} + +interface ParcelRowItem { + type: 'parcel'; + parcel: FarmParcelModel; +} + +type Item = FarmHeaderItem | ParcelRowItem; + +const ROW_HEIGHT_DESKTOP = 112; +const ROW_HEIGHT_MOBILE = 80; +const HEADER_HEIGHT = 56; +const LIST_HEIGHT_DESKTOP = 480; +const LIST_HEIGHT_MOBILE = 360; + +const UNKNOWN_FARM = '__unknown__'; + +const matchesSearch = (p: FarmParcelModel, q: string, farmName: string): boolean => { + if (!q) return true; + const hay = [ + p.identifier, p.category, p.inRegion, p.hasToponym, p.description, farmName, + ].filter(Boolean).join(' ').toLowerCase(); + return hay.includes(q.toLowerCase()); +}; + +const ParcelSelectionList: React.FC = ({ parcels, selectedParcelId, f, farmNamesById = {} }) => { const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const ROW_HEIGHT = isMobile ? ROW_HEIGHT_MOBILE : ROW_HEIGHT_DESKTOP; + const LIST_HEIGHT = isMobile ? LIST_HEIGHT_MOBILE : LIST_HEIGHT_DESKTOP; + const [search, setSearch] = useState(''); + const [collapsedFarms, setCollapsedFarms] = useState>(new Set()); + const listRef = useRef(null); - return ( - parcels.map((p) => { - const isSelected = p["@id"] === selectedParcelId; - const parcelImage = (p.depiction || "").trim() || placeholder; - + const toggleFarm = (id: string) => { + setCollapsedFarms(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const items = useMemo(() => { + const grouped = new Map(); + for (const p of parcels) { + const fid = p.farm?.["@id"] ?? UNKNOWN_FARM; + const farmName = fid === UNKNOWN_FARM ? '(no farm)' : (farmNamesById[fid] ?? 'Unknown farm'); + if (!matchesSearch(p, search, farmName)) continue; + if (!grouped.has(fid)) grouped.set(fid, []); + grouped.get(fid)!.push(p); + } + + const sortedFarmIds = Array.from(grouped.keys()).sort((a, b) => { + const na = a === UNKNOWN_FARM ? '' : (farmNamesById[a] ?? a); + const nb = b === UNKNOWN_FARM ? '' : (farmNamesById[b] ?? b); + return na.localeCompare(nb); + }); + + const out: Item[] = []; + const isSearching = !!search.trim(); + for (const fid of sortedFarmIds) { + const collapsed = !isSearching && collapsedFarms.has(fid); + const farmName = fid === UNKNOWN_FARM ? '(no farm)' : (farmNamesById[fid] ?? 'Unknown farm'); + const farmParcels = grouped.get(fid)!; + out.push({ + type: 'farm-header', + farmId: fid, + farmName, + parcelCount: farmParcels.length, + collapsed, + }); + if (!collapsed) { + for (const p of farmParcels) { + out.push({ type: 'parcel', parcel: p }); + } + } + } + return out; + }, [parcels, search, collapsedFarms, farmNamesById]); + + const getItemSize = (index: number) => items[index].type === 'farm-header' ? HEADER_HEIGHT : ROW_HEIGHT; + + useEffect(() => { + listRef.current?.resetAfterIndex(0); + }, [items]); + + const Row = ({ index, style }: ListChildComponentProps) => { + const item = items[index]; + if (item.type === 'farm-header') { return ( toggleFarm(item.farmId)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + padding: 1.5, + cursor: 'pointer', + boxSizing: 'border-box', + width: '100%', + overflow: 'hidden', + backgroundColor: theme.palette.background.default, + borderBottom: `1px solid ${theme.palette.divider}`, + '&:hover': { backgroundColor: alpha(theme.palette.primary.main, 0.05) }, + }} + > + {item.collapsed ? : } + + {item.farmName} + + + {item.parcelCount} parcel{item.parcelCount === 1 ? '' : 's'} + + + ); + } + + const p = item.parcel; + const isSelected = p["@id"] === selectedParcelId; + const parcelImage = (p.depiction || '').trim() || placeholder; + return ( + + f(p)} sx={{ display: 'flex', alignItems: 'center', - padding: 2, + padding: { xs: 1, sm: 2 }, boxShadow: 2, borderRadius: 2, cursor: 'pointer', + height: '100%', + boxSizing: 'border-box', + width: '100%', + overflow: 'hidden', transition: 'background-color 200ms cubic-bezier(0.4, 0, 0.2, 1)', - backgroundColor: isSelected ? alpha(theme.palette.primary.main, 0.08) : 'white', + backgroundColor: isSelected ? alpha(theme.palette.primary.main, 0.08) : theme.palette.background.paper, '&:hover': { backgroundColor: isSelected ? alpha(theme.palette.primary.main, 0.2) : alpha('rgb(0, 0, 0)', 0.04), } }} - onClick={() => f(p)} > - - - + + {p.identifier} - - {p["@type"]} + + {[p.category, p.inRegion].filter(Boolean).join(' ยท ') || p["@type"]} - ); - }) + + ); + }; + + if (parcels.length === 0) { + return No parcels available.; + } + + return ( + + setSearch(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + endAdornment: search ? ( + + setSearch('')} aria-label="clear"> + + + + ) : undefined, + } + }} + /> + {items.length === 0 ? ( + No matching parcels. + ) : ( + acc + getItemSize(i), 0))} + itemCount={items.length} + itemSize={getItemSize} + width="100%" + style={{ overflowX: 'hidden' }} + > + {Row} + + )} + ); }; -export default ParcelSelectionList; \ No newline at end of file +export default ParcelSelectionList; diff --git a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.types.ts b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.types.ts index 26ef498..fece62c 100644 --- a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.types.ts +++ b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.types.ts @@ -4,4 +4,5 @@ export interface ParcelSelectionListProps { parcels: FarmParcelModel[]; selectedParcelId?: string; f: (parcel?: FarmParcelModel) => void; -} \ No newline at end of file + farmNamesById?: Record; +} diff --git a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionModule.tsx b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionModule.tsx index 653e694..cfbeaeb 100644 --- a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionModule.tsx +++ b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionModule.tsx @@ -1,8 +1,9 @@ import useFetch from "@hooks/useFetch"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import GenericSnackbar from "../../shared/GenericSnackbar/GenericSnackbar"; import useSnackbar from "@hooks/useSnackbar"; import { FarmParcelModel } from "@models/FarmParcel"; +import { FarmModel } from "@models/Farm"; import { useSession } from "@contexts/SessionContext"; import { Box, Button, Skeleton } from "@mui/material"; import Accordion from '@mui/material/Accordion'; @@ -16,6 +17,7 @@ import ClearIcon from '@mui/icons-material/Clear'; const ParcelSelectionModule = () => { const [parcels, setParcels] = useState([]); + const [farms, setFarms] = useState([]); const { session, setSession } = useSession(); const [expanded, setExpanded] = useState(!session?.farm_parcel); @@ -26,12 +28,28 @@ const ParcelSelectionModule = () => { } ); + const { fetchData: fetchFarms, response: farmsResponse } = useFetch( + "proxy/farmcalendar/api/v1/Farm/?format=json", + { method: 'GET' }, + ); + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); useEffect(() => { - fetchData() + fetchData(); + fetchFarms(); }, []) + useEffect(() => { + if (Array.isArray(farmsResponse)) setFarms(farmsResponse); + }, [farmsResponse]) + + const farmNamesById = useMemo(() => { + const map: Record = {}; + for (const f of farms) map[f["@id"]] = f.name; + return map; + }, [farms]) + useEffect(() => { if (error) { showSnackbar('error', 'Error loading parcel list'); @@ -85,7 +103,7 @@ const ParcelSelectionModule = () => { - +