From d92a03c3a2c8c629aaa31265b3872a02b7a9dc28 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:05:48 +0100 Subject: [PATCH 01/54] feat: add shadcn/ui scroll area --- fdm-app/app/components/ui/scroll-area.tsx | 48 +++++++++++++++++++++++ fdm-app/package.json | 1 + pnpm-lock.yaml | 3 ++ 3 files changed, 52 insertions(+) create mode 100644 fdm-app/app/components/ui/scroll-area.tsx diff --git a/fdm-app/app/components/ui/scroll-area.tsx b/fdm-app/app/components/ui/scroll-area.tsx new file mode 100644 index 000000000..ffb09985b --- /dev/null +++ b/fdm-app/app/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "~/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef< + typeof ScrollAreaPrimitive.ScrollAreaScrollbar + > +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/fdm-app/package.json b/fdm-app/package.json index b28267d5b..0a2a8761d 100644 --- a/fdm-app/package.json +++ b/fdm-app/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 684da2c64..ce6c74482 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: '@radix-ui/react-progress': specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-scroll-area': + specifier: ^1.2.10 + version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-separator': specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) From a50bb0fb91ce806afcd11339992846b097f1de01 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:00:03 +0100 Subject: [PATCH 02/54] feat: Show fertilizer icon in the fields table for fertilizers --- .changeset/fluffy-sides-like.md | 5 ++ .../app/components/blocks/fields/columns.tsx | 47 +++++++++++++------ .../app/components/blocks/fields/table.tsx | 4 +- ...farm.$b_id_farm.$calendar.field._index.tsx | 11 ++++- 4 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 .changeset/fluffy-sides-like.md diff --git a/.changeset/fluffy-sides-like.md b/.changeset/fluffy-sides-like.md new file mode 100644 index 000000000..be8e6c987 --- /dev/null +++ b/.changeset/fluffy-sides-like.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": minor +--- + +Show fertilizer icon in the fields table for fertilizers diff --git a/fdm-app/app/components/blocks/fields/columns.tsx b/fdm-app/app/components/blocks/fields/columns.tsx index a083ffcc1..4ac51ea59 100644 --- a/fdm-app/app/components/blocks/fields/columns.tsx +++ b/fdm-app/app/components/blocks/fields/columns.tsx @@ -1,5 +1,12 @@ import type { ColumnDef } from "@tanstack/react-table" -import { ArrowUpRightFromSquare, MoreHorizontal } from "lucide-react" +import { + ArrowUpRightFromSquare, + Circle, + Diamond, + MoreHorizontal, + Square, + Triangle, +} from "lucide-react" import { NavLink } from "react-router-dom" import { getCultivationColor } from "~/components/custom/cultivation-colors" import { Badge } from "~/components/ui/badge" @@ -21,9 +28,11 @@ export type FieldExtended = { b_lu_name: string b_lu_croprotation: string b_lu_start: Date - }[] - fertilizerApplications: { + }[] + fertilizers: { p_name_nl: string + p_id: string + p_type: string }[] a_som_loi: number b_soiltype_agr: string @@ -119,9 +128,9 @@ export const columns: ColumnDef[] = [ enableSorting: true, sortingFn: (rowA, rowB, _columnId) => { const fertilizerA = - rowA.original.fertilizerApplications[0]?.p_name_nl || "" + rowA.original.fertilizers[0].p_name_nl || "" const fertilizerB = - rowB.original.fertilizerApplications[0]?.p_name_nl || "" + rowB.original.fertilizers[0].p_name_nl || "" return fertilizerA.localeCompare(fertilizerB) }, header: ({ column }) => { @@ -130,18 +139,28 @@ export const columns: ColumnDef[] = [ ) }, cell: ({ row }) => { - const field = row.original - - const uniqueFertilizerNames = [...field.fertilizerApplications] - .map((app) => app.p_name_nl) - .filter((name, index, self) => self.indexOf(name) === index) - .sort((a, b) => a.localeCompare(b)) + const fertilizers = row.original.fertilizers return (
- {uniqueFertilizerNames.map((fertilizer) => ( - - {fertilizer} + {fertilizers.map((fertilizer) => ( + + + {fertilizer.p_type === "manure" ? ( + + ) : fertilizer.p_type === "mineral" ? ( + + ) : fertilizer.p_type === "compost" ? ( + + ) : ( + + )} + + {fertilizer.p_name_nl} ))}
diff --git a/fdm-app/app/components/blocks/fields/table.tsx b/fdm-app/app/components/blocks/fields/table.tsx index a89f1099b..c8fe909a2 100644 --- a/fdm-app/app/components/blocks/fields/table.tsx +++ b/fdm-app/app/components/blocks/fields/table.tsx @@ -117,7 +117,7 @@ export function DataTable({ const memoizedData = useMemo(() => { return data.map((item) => ({ ...item, - searchTarget: `${item.b_name} ${item.cultivations.map((c) => c.b_lu_name).join(" ")} ${item.fertilizerApplications.map((f) => f.p_name_nl).join(" ")} ${item.b_soiltype_agr}`, + searchTarget: `${item.b_name} ${item.cultivations.map((c) => c.b_lu_name).join(" ")} ${item.fertilizers.map((f) => f.p_name_nl).join(" ")} ${item.b_soiltype_agr}`, })) }, [data]) @@ -259,7 +259,7 @@ export function DataTable({ -
+
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx index 17a455441..9062819d1 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx @@ -3,6 +3,7 @@ import { getCurrentSoilData, getFarms, getFertilizerApplications, + getFertilizers, getFields, } from "@svenvw/fdm-core" import { @@ -111,6 +112,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } }) + const fertilizers = await getFertilizers(fdm, session.principal_id, b_id_farm) + const fieldsExtended = await Promise.all( fields.map(async (field) => { const cultivations = await getCultivations( @@ -127,6 +130,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { timeframe, ) + const fertilizersFiltered = fertilizers.filter((fertilizer) => { + return fertilizerApplications.some((application) => { + return application.p_id === fertilizer.p_id + }) + }) + const currentSoilData = await getCurrentSoilData( fdm, session.principal_id, @@ -145,7 +154,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { b_id: field.b_id, b_name: field.b_name, cultivations: cultivations, - fertilizerApplications: fertilizerApplications, + fertilizers: fertilizersFiltered, a_som_loi: a_som_loi, b_soiltype_agr: b_soiltype_agr, b_area: Math.round(field.b_area * 10) / 10, From ada7a6a6c6390f32aebf250b33341e2067c6b7c1 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:23:47 +0100 Subject: [PATCH 03/54] feat: add a rotation page --- .changeset/goofy-results-wait.md | 5 + .../blocks/rotation/column-header.tsx | 71 +++ .../components/blocks/rotation/columns.tsx | 414 ++++++++++++++++++ .../app/components/blocks/rotation/table.tsx | 370 ++++++++++++++++ .../app/components/blocks/sidebar/farm.tsx | 38 +- ...farm.$b_id_farm.$calendar.field._index.tsx | 7 +- ...m.$b_id_farm.$calendar.rotation._index.tsx | 376 ++++++++++++++++ 7 files changed, 1272 insertions(+), 9 deletions(-) create mode 100644 .changeset/goofy-results-wait.md create mode 100644 fdm-app/app/components/blocks/rotation/column-header.tsx create mode 100644 fdm-app/app/components/blocks/rotation/columns.tsx create mode 100644 fdm-app/app/components/blocks/rotation/table.tsx create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx diff --git a/.changeset/goofy-results-wait.md b/.changeset/goofy-results-wait.md new file mode 100644 index 000000000..5f8e7905a --- /dev/null +++ b/.changeset/goofy-results-wait.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": minor +--- + +Add a rotation page that shows per cultivation the details in a table diff --git a/fdm-app/app/components/blocks/rotation/column-header.tsx b/fdm-app/app/components/blocks/rotation/column-header.tsx new file mode 100644 index 000000000..27922207e --- /dev/null +++ b/fdm-app/app/components/blocks/rotation/column-header.tsx @@ -0,0 +1,71 @@ +import type { Column } from "@tanstack/react-table" +import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from "lucide-react" +import { Button } from "~/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" +import { cn } from "~/lib/utils" + +interface DataTableColumnHeaderProps + extends React.HTMLAttributes { + column: Column + title: string +} + +export function DataTableColumnHeader({ + column, + title, + className, +}: DataTableColumnHeaderProps) { + if (!column.getCanSort()) { + return
{title}
+ } + + return ( +
+ + + + + + column.toggleSorting(false)} + > + + Oplopend + + column.toggleSorting(true)} + > + + Aflopend + + + column.toggleVisibility(false)} + > + + Verberg + + + +
+ ) +} diff --git a/fdm-app/app/components/blocks/rotation/columns.tsx b/fdm-app/app/components/blocks/rotation/columns.tsx new file mode 100644 index 000000000..987b591a3 --- /dev/null +++ b/fdm-app/app/components/blocks/rotation/columns.tsx @@ -0,0 +1,414 @@ +import type { ColumnDef } from "@tanstack/react-table" +import { NavLink } from "react-router-dom" +import { getCultivationColor } from "~/components/custom/cultivation-colors" +import { Badge } from "~/components/ui/badge" +import { ScrollArea } from "~/components/ui/scroll-area" +import { Button } from "~/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "./column-header" +import { format } from "date-fns" +import { nl } from "date-fns/locale/nl" +import { Checkbox } from "~/components/ui/checkbox" +import { Circle, Diamond, Square, Triangle } from "lucide-react" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip" + +export type RotationExtended = { + b_lu_catalogue: string + b_lu: string[] + b_lu_name: string + b_lu_croprotation: string + b_lu_start: Date[] + b_lu_end: Date[] + fields: { + b_id: string + b_name: string + b_area: number + b_isproductive: boolean + a_som_loi: number + b_soiltype_agr: string + harvests: any + fertilizerApplications: { + p_name_nl: string + p_id: string + p_type: string + }[] + fertilizers: { + p_name_nl: string + p_id: string + p_type: string + }[] + }[] +} + +export const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + + table.toggleAllPageRowsSelected(!!value) + } + aria-label="Selecteer alle rijen" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Selecteer deze rij" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "b_lu_name", + enableSorting: true, + header: ({ column }) => { + return + }, + cell: ({ row }) => { + const cultivation = row.original + return ( + + {cultivation.b_lu_name} + + ) + }, + }, + { + accessorKey: "b_lu_start", + enableSorting: true, + sortingFn: "alphanumeric", + header: ({ column }) => { + return + }, + enableHiding: true, // Enable hiding for mobile + cell: ({ row }) => { + const cultivation = row.original + + const b_lu_start = cultivation.b_lu_start + + if (b_lu_start.length === 1) { + return ( +

+ {format(b_lu_start[0], "PP", { locale: nl })} +

+ ) + } + const firstDate = b_lu_start[0] + const lastDate = b_lu_start[b_lu_start.length - 1] + return ( +

+ {`${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}`} +

+ ) + }, + }, + { + accessorKey: "b_harvest_date", + enableSorting: true, + sortingFn: "alphanumeric", + header: ({ column }) => { + return + }, + enableHiding: true, // Enable hiding for mobile + cell: ({ row }) => { + const cultivation = row.original + + const b_lu_start = cultivation.field.harvests + + if (b_lu_start.length === 1) { + return ( +

+ {format(b_lu_start[0], "PP", { locale: nl })} +

+ ) + } + const firstDate = b_lu_start[0] + const lastDate = b_lu_start[b_lu_start.length - 1] + return ( +

+ {`${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}`} +

+ ) + }, + }, + { + accessorKey: "fertilizerApplications", + enableSorting: false, + enableHiding: true, // Enable hiding for mobile + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const fields = row.original.fields + const fertilizers = fields.flatMap((field) => field.fertilizers) + const uniqueFertilizers = Array.from( + new Map(fertilizers.map((f) => [f.p_id, f])).values(), + ) + + return ( +
+ {uniqueFertilizers.map((fertilizer) => { + const isFertilizerUsedOnAllFieldsForThisCultivation = + fields.every((field) => + field.fertilizers.some( + (f) => f.p_id === fertilizer.p_id, + ), + ) + const fertilizerIconFillShade = + isFertilizerUsedOnAllFieldsForThisCultivation + ? "600" + : "300" + + return ( + + + + + {fertilizer.p_type === "manure" ? ( + + ) : fertilizer.p_type === + "mineral" ? ( + + ) : fertilizer.p_type === + "compost" ? ( + + ) : ( + + )} + + {fertilizer.p_name_nl} + + + + {isFertilizerUsedOnAllFieldsForThisCultivation + ? "Deze meststof is toegepast op alle percelen met dit gewas" + : "Deze meststof is op sommige percelen met dit gewas toegepast"} + + + ) + })} +
+ ) + }, + }, + { + accessorKey: "b_name", + enableSorting: true, + enableHiding: true, // Enable hiding for mobile + header: ({ column }) => { + return + }, + cell: ({ row }) => { + const cultivation = row.original + + const fieldsSorted = [...cultivation.fields].sort((a, b) => + a.b_name.localeCompare(b.b_name), + ) + return ( + + + + + + +
+ {fieldsSorted.map((field, idx) => ( + + + {field.b_name} + + + ))} +
+
+
+
+ ) + }, + }, + { + accessorKey: "b_area", + enableSorting: true, + sortingFn: "alphanumeric", + header: ({ column }) => { + return + }, + enableHiding: true, // Enable hiding for mobile + cell: ({ row }) => { + const cultivation = row.original + + const b_area = cultivation.fields.reduce( + (acc, field) => acc + field.b_area, + 0, + ) + + return ( +

+ {b_area < 0.1 ? "< 0.1 ha" : `${b_area.toFixed(1)} ha`} +

+ ) + }, + }, + + // { + // accessorKey: "a_som_loi", + // enableSorting: true, + // sortingFn: "alphanumeric", + // header: ({ column }) => { + // return + // }, + // enableHiding: true, // Enable hiding for mobile + // cell: ({ row }) => { + // const field = row.original + // return ( + //

+ // {`${field.a_som_loi.toFixed(2)} %`} + //

+ // ) + // }, + // }, + // { + // accessorKey: "b_soiltype_agr", + // enableSorting: true, + // sortingFn: "alphanumeric", + // header: ({ column }) => { + // return + // }, + // enableHiding: true, // Enable hiding for mobile + // cell: ({ row }) => { + // const field = row.original + // return ( + //

{field.b_soiltype_agr}

+ // ) + // }, + // }, + // { + // accessorKey: "b_area", + // enableSorting: true, + // sortingFn: "alphanumeric", + // header: ({ column }) => { + // return + // }, + // enableHiding: true, // Enable hiding for mobile + // cell: ({ row }) => { + // const field = row.original + // return ( + //

+ // {field.b_area < 0.1 + // ? "< 0.1 ha" + // : `${field.b_area.toFixed(1)} ha`} + //

+ // ) + // }, + // }, + // { + // id: "actions", + // enableHiding: false, + // cell: ({ row }) => { + // const field = row.original + + // return ( + // + // + // + // + // + // {/* Acties + // + // navigator.clipboard.writeText(field.b_id) + // } + // > + // Kopieer perceel id + // + // */} + // Gegevens + // + // Overzicht + // + // + // + // Gewassen + // + // + // + // + // Bemesting + // + // + // + // Bodem + // + // + // + // Kaart + // + // + // + // + // Verwijderen + // + // + // + // + // ) + // }, + // }, +] diff --git a/fdm-app/app/components/blocks/rotation/table.tsx b/fdm-app/app/components/blocks/rotation/table.tsx new file mode 100644 index 000000000..f918418ec --- /dev/null +++ b/fdm-app/app/components/blocks/rotation/table.tsx @@ -0,0 +1,370 @@ +import { + type ColumnDef, + type ColumnFiltersState, + type FilterFn, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + type Row, + type RowSelectionState, + type SortingState, + useReactTable, + type VisibilityState, +} from "@tanstack/react-table" +import fuzzysort from "fuzzysort" +import { ChevronDown, Plus } from "lucide-react" +import { useEffect, useMemo, useRef, useState } from "react" +import { NavLink, useParams } from "react-router-dom" +import { Button } from "~/components/ui/button" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" +import { Input } from "~/components/ui/input" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/ui/tooltip" +import { useIsMobile } from "~/hooks/use-mobile" +import { cn } from "~/lib/utils" +import { FieldFilterToggle } from "../../custom/field-filter-toggle" +import type { RotationExtended } from "./columns" +import { format } from "date-fns" +import { nl } from "date-fns/locale/nl" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) + const [globalFilter, setGlobalFilter] = useState("") + const isMobile = useIsMobile() + const [columnVisibility, setColumnVisibility] = useState( + isMobile + ? { a_som_loi: false, b_soiltype_agr: false, b_area: false } + : {}, + ) + const [rowSelection, setRowSelection] = useState({}) + const lastSelectedRowIndex = useRef(null) + + useEffect(() => { + setColumnVisibility( + isMobile + ? { a_som_loi: false, b_soiltype_agr: false, b_area: false } + : {}, + ) + }, [isMobile]) + + const params = useParams() + const b_id_farm = params.b_id_farm + const calendar = params.calendar + + const handleRowClick = ( + row: Row, + event: React.MouseEvent, + ) => { + // Ignore clicks on interactive elements inside the row + const isInteractive = (target: EventTarget | null): boolean => { + if (!(target instanceof Element)) return false + return !!target.closest( + 'a,button,input,label,select,textarea,[role="button"],[role="link"],[role="checkbox"],[data-prevent-row-click="true"]', + ) + } + + if (isInteractive(event.target)) { + // If a link was clicked, let the default navigation happen + return + } + + if (event.shiftKey && lastSelectedRowIndex.current !== null) { + const currentIndex = row.index + const start = Math.min(currentIndex, lastSelectedRowIndex.current) + const end = Math.max(currentIndex, lastSelectedRowIndex.current) + + const rowsToSelect = table + .getRowModel() + .rows.slice(start, end + 1) + .map((r) => r.id) + + const newRowSelection = { ...rowSelection } + rowsToSelect.forEach((id) => { + newRowSelection[id] = true + }) + setRowSelection(newRowSelection) + } else { + row.toggleSelected() + } + lastSelectedRowIndex.current = row.index + } + + const memoizedData = useMemo(() => { + return data.map((item) => ({ + ...item, + searchTarget: `${item.b_lu_name} ${[ + ...new Set( + item.b_lu_start.map((date) => + format(date, "dd MMMM yyy", { locale: nl }), + ), + ), + ].join(" ")}`, + })) + }, [data]) + // console.log(memoizedData.searchTarget) + + const fuzzyFilter: FilterFn = (row, _columnId, filterValue) => { + const result = fuzzysort.go(filterValue, [ + (row.original as any).searchTarget, + ]) + return result.length > 0 + } + + const table = useReactTable({ + data: memoizedData, + columns, + getCoreRowModel: getCoreRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onGlobalFilterChange: setGlobalFilter, + onRowSelectionChange: setRowSelection, + globalFilterFn: fuzzyFilter, + state: { + sorting, + columnFilters, + columnVisibility, + globalFilter, + rowSelection, + }, + }) + + // biome-ignore lint/correctness/useExhaustiveDependencies: rowSelection is needed for Bemesting button activation + const selectedCultivations = useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original) + }, [table, rowSelection]) + + const selectedCultivationIds = selectedCultivations.map( + (field) => field.b_lu_catalogue, + ) + + const isFertilizerButtonDisabled = selectedCultivationIds.length === 0 + const fertilizerTooltipContent = isFertilizerButtonDisabled + ? "Selecteer één of meerdere gewassen om bemesting toe te voegen" + : "Bemesting toevoegen aan geselecteerd gewas" + + const isHarvestButtonDisabled = selectedCultivationIds.length !== 1 + const harvestTooltipContent = isHarvestButtonDisabled + ? "Selecteer één gewas om oogst toe te voegen" + : "Oogst toevoegen aan geselecteerd gewas" + + return ( +
+
+ setGlobalFilter(event.target.value)} + className="w-full sm:w-auto sm:grow" + /> +
+ + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + const columnNames: Record = + { + b_name: "Naam", + cultivations: "Gewassen", + fertilizerApplications: + "Bemesting met:", + a_som_loi: "OS", + b_soiltype_agr: "Bodemtype", + b_area: "Oppervlakte", + } + return ( + + column.toggleVisibility(!!value) + } + > + {columnNames[column.id] ?? + column.id} + + ) + })} + + + + + + +
+ {isFertilizerButtonDisabled ? ( + + ) : ( + + + + )} +
+
+ +

{fertilizerTooltipContent}

+
+
+
+ + + +
+ {isHarvestButtonDisabled ? ( + + ) : ( + + + + )} +
+
+ +

{harvestTooltipContent}

+
+
+
+
+
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext(), + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + handleRowClick(row, event) + } + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + Geen resultaten. + + + )} + +
+
+
+ ) +} diff --git a/fdm-app/app/components/blocks/sidebar/farm.tsx b/fdm-app/app/components/blocks/sidebar/farm.tsx index c3fce2dec..5986bcd41 100644 --- a/fdm-app/app/components/blocks/sidebar/farm.tsx +++ b/fdm-app/app/components/blocks/sidebar/farm.tsx @@ -4,6 +4,7 @@ import { ChevronRight, House, Shapes, + Sprout, Square, } from "lucide-react" import { useState } from "react" @@ -64,6 +65,15 @@ export function SidebarFarm() { fieldsLink = undefined } + let rotationLink: string | undefined + if (isCreateFarmWizard) { + rotationLink = undefined + } else if (farmId && farmId !== "undefined") { + rotationLink = `/farm/${farmId}/${selectedCalendar}/rotation` + } else { + rotationLink = undefined + } + let fertilizersLink: string | undefined if (farmId && farmId !== "undefined") { fertilizersLink = `/farm/${farmId}/fertilizers` @@ -190,14 +200,26 @@ export function SidebarFarm() { )} - {/* - - - - Gewassen - - - */} + + {rotationLink ? ( + + + + Bouwplan + + + ) : ( + + + + Bouwplan + + + )} + {fertilizersLink ? ( diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx index 9062819d1..0c0609a74 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx @@ -5,6 +5,7 @@ import { getFertilizerApplications, getFertilizers, getFields, + getHarvests, } from "@svenvw/fdm-core" import { data, @@ -112,7 +113,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } }) - const fertilizers = await getFertilizers(fdm, session.principal_id, b_id_farm) + const fertilizers = await getFertilizers( + fdm, + session.principal_id, + b_id_farm, + ) const fieldsExtended = await Promise.all( fields.map(async (field) => { diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx new file mode 100644 index 000000000..f2b4a0569 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx @@ -0,0 +1,376 @@ +import { + getCultivations, + getCurrentSoilData, + getFarms, + getFertilizerApplications, + getFertilizers, + getFields, + getHarvests, +} from "@svenvw/fdm-core" +import { + data, + type LoaderFunctionArgs, + type MetaFunction, + NavLink, + redirect, + useLoaderData, +} from "react-router" +import { FarmContent } from "~/components/blocks/farm/farm-content" +import { FarmTitle } from "~/components/blocks/farm/farm-title" +import { columns, RotationExtended } from "~/components/blocks/rotation/columns" +import { DataTable } from "~/components/blocks/rotation/table" +import { Header } from "~/components/blocks/header/base" +import { HeaderFarm } from "~/components/blocks/header/farm" +import { BreadcrumbItem, BreadcrumbSeparator } from "~/components/ui/breadcrumb" +import { Button } from "~/components/ui/button" +import { SidebarInset } from "~/components/ui/sidebar" +import { getSession } from "~/lib/auth.server" +import { getTimeframe } from "~/lib/calendar" +import { clientConfig } from "~/lib/config" +import { handleLoaderError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" +import { useFieldFilterStore } from "~/store/field-filter" + +export const meta: MetaFunction = () => { + return [ + { title: `Perceel | ${clientConfig.name}` }, + { + name: "description", + content: + "Beheer al uw percelen op één plek. Bekijk een overzicht van alle percelen binnen uw bedrijf met hun belangrijkste kenmerken.", + }, + ] +} + +/** + * Retrieves and processes farm and field options for the specified farm ID based on the current user session. + * + * This loader function extracts the active farm ID from the route parameters and uses the user's session to: + * - Fetch all farms associated with the user, redirecting to the farms overview if none exist. + * - Validate and map the farms into selectable options. + * - Retrieve and validate the fields for the active farm, rounding each field's area and sorting the fields alphabetically. + * + * @throws {Response} When the required farm ID is missing from the route parameters. + * @throws {Error} When a farm or field lacks the necessary data structure. + * + * @returns An object containing: + * - b_id_farm: The active farm ID. + * - farmOptions: An array of validated farm options. + * - fieldOptions: A sorted array of processed field options. + * - userName: The name of the current user. + */ +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + // Get the active farm + const b_id_farm = params.b_id_farm + if (!b_id_farm) { + throw data("missing: b_id_farm", { + status: 400, + statusText: "missing: b_id_farm", + }) + } + + // Get the session + const session = await getSession(request) + + // Get timeframe from calendar store + const timeframe = getTimeframe(params) + + // Get a list of possible farms of the user + const farms = await getFarms(fdm, session.principal_id) + + // Redirect to farms overview if user has no farm + if (farms.length === 0) { + return redirect("./farm") + } + + // Get farms to be selected + const farmOptions = farms.map((farm) => { + if (!farm?.b_id_farm || !farm?.b_name_farm) { + throw new Error("Invalid farm data structure") + } + return { + b_id_farm: farm.b_id_farm, + b_name_farm: farm.b_name_farm, + } + }) + + // Get the fields to be selected + const fields = await getFields( + fdm, + session.principal_id, + b_id_farm, + timeframe, + ) + const fieldOptions = fields.map((field) => { + if (!field?.b_id || !field?.b_name) { + throw new Error("Invalid field data structure") + } + return { + b_id: field.b_id, + b_name: field.b_name, + b_area: Math.round(field.b_area * 10) / 10, + } + }) + + const fertilizers = await getFertilizers( + fdm, + session.principal_id, + b_id_farm, + ) + + const fieldsExtended = await Promise.all( + fields.map(async (field) => { + const cultivations = await getCultivations( + fdm, + session.principal_id, + field.b_id, + timeframe, + ) + + const harvests = await Promise.all( + cultivations.map(async (cultivation) => { + return await getHarvests( + fdm, + session.principal_id, + cultivation.b_lu, + timeframe, + ) + }), + ) + + const fertilizerApplications = await getFertilizerApplications( + fdm, + session.principal_id, + field.b_id, + timeframe, + ) + + const fertilizersFiltered = fertilizers.filter((fertilizer) => { + return fertilizerApplications.some((application) => { + return application.p_id === fertilizer.p_id + }) + }) + + const currentSoilData = await getCurrentSoilData( + fdm, + session.principal_id, + field.b_id, + timeframe, + ) + const a_som_loi = + currentSoilData.find((x) => x.parameter === "a_som_loi") + ?.value ?? null + const b_soiltype_agr = + currentSoilData.find( + (x) => x.parameter === "b_soiltype_agr", + )?.value ?? null + + return { + b_id: field.b_id, + b_name: field.b_name, + cultivations: cultivations, + harvests: harvests, + fertilizerApplications: fertilizerApplications, + fertilizers: fertilizersFiltered, + a_som_loi: a_som_loi, + b_soiltype_agr: b_soiltype_agr, + b_area: Math.round(field.b_area * 10) / 10, + b_isproductive: field.b_isproductive ?? true, + } + }), + ) + + // Transform fieldsExtended to rotationExtended + const cultivationsInRotation: string[] = [ + ...new Set( + fieldsExtended.flatMap((field) => { + return field.cultivations.flatMap((cultivation) => { + return cultivation.b_lu_catalogue + }) + }), + ), + ] + + const rotationExtended: RotationExtended[] = cultivationsInRotation.map( + (b_lu_catalogue) => { + const cultivationsForCatalogue = fieldsExtended.flatMap( + (field) => + field.cultivations.filter( + (cultivation) => + cultivation.b_lu_catalogue === b_lu_catalogue, + ), + ) + + const fieldsWithThisCultivation = fieldsExtended.filter( + (field) => + field.cultivations.some( + (cultivation) => + cultivation.b_lu_catalogue === b_lu_catalogue, + ), + ) + + // Get all unique b_lu_start of cultivation + const b_lu_start = [ + ...new Set( + cultivationsForCatalogue.map((cultivation) => + cultivation.b_lu_start.getTime(), + ), + ), + ].map((b_lu_start) => new Date(b_lu_start)) + + const b_lu_end = [ + ...new Set( + cultivationsForCatalogue.map((cultivation) => + cultivation.b_lu_start.getTime(), + ), + ), + ].map((b_lu_end) => new Date(b_lu_end)) + + // Get all harvests for this cultivation + const harvestsFiltered = fieldsWithThisCultivation.flatMap( + (field) => + field.harvests.filter((harvest) => + cultivationsForCatalogue.some( + (cultivation) => + cultivation.b_lu === harvest.b_lu, + ), + ), + ) + + return { + b_lu_catalogue: b_lu_catalogue, + b_lu: cultivationsForCatalogue.map( + (cultivation) => cultivation.b_lu, + ), + b_lu_name: cultivationsForCatalogue[0]?.b_lu_name ?? "", + b_lu_croprotation: + cultivationsForCatalogue[0]?.b_lu_croprotation ?? "", + b_lu_start: b_lu_start, + b_lu_end: b_lu_end, + fields: fieldsWithThisCultivation.map((field) => ({ + b_id: field.b_id, + b_name: field.b_name, + b_area: field.b_area, + b_isproductive: field.b_isproductive, + a_som_loi: field.a_som_loi ?? 0, + b_soiltype_agr: field.b_soiltype_agr ?? "", + harvests: harvestsFiltered, + fertilizerApplications: + field.fertilizerApplications.map((app) => ({ + p_name_nl: app.p_name_nl, + p_id: app.p_id, + p_type: app.p_type, + })), + fertilizers: field.fertilizers.map((app) => ({ + p_name_nl: app.p_name_nl, + p_id: app.p_id, + p_type: app.p_type, + })), + })), + } + }, + ) + + // Return user information from loader + return { + b_id_farm: b_id_farm, + farmOptions: farmOptions, + fieldOptions: fieldOptions, + rotationExtended: rotationExtended, + userName: session.userName, + } + } catch (error) { + throw handleLoaderError(error) + } +} + +/** + * Renders a user interface for selecting or creating a field within a farm. + * + * This component retrieves loader data to access the available farm options, field options, and user information. + * Depending on whether fields exist, it either displays: + * - A welcome screen prompting the user to create a new field if no fields are present. + * - A list of existing fields with selection controls and a time-based greeting for navigation. + * + * @example + * + */ +export default function FarmRotationIndex() { + const loaderData = useLoaderData() + const { showProductiveOnly } = useFieldFilterStore() + + const filteredRotations = loaderData.rotationExtended.filter((rotation) => { + if (!showProductiveOnly) { + return true + } + return rotation.fields.some((field) => field.b_isproductive) + }) + + const currentFarmName = + loaderData.farmOptions.find( + (farm) => farm.b_id_farm === loaderData.b_id_farm, + )?.b_name_farm ?? "" + + return ( + +
+ + + + + Bouwplan + +
+
+ {loaderData.fieldOptions.length === 0 ? ( + <> + +
+
+

+ Het lijkt erop dat je nog geen bouwplan hebt + :( +

+
+
+ + + +
+
+ + ) : ( + <> +
+ +
+ +
+ +
+
+ + )} +
+
+ ) +} From 5fc1657d2d0299e26c2e540c07b5aad52aa3a326 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 7 Nov 2025 13:50:33 +0100 Subject: [PATCH 04/54] feat: add harvest dates to table --- .../components/blocks/rotation/columns.tsx | 207 ++++++++---------- ...m.$b_id_farm.$calendar.rotation._index.tsx | 47 ++-- 2 files changed, 117 insertions(+), 137 deletions(-) diff --git a/fdm-app/app/components/blocks/rotation/columns.tsx b/fdm-app/app/components/blocks/rotation/columns.tsx index 987b591a3..4cd4bb0d9 100644 --- a/fdm-app/app/components/blocks/rotation/columns.tsx +++ b/fdm-app/app/components/blocks/rotation/columns.tsx @@ -26,6 +26,7 @@ export type RotationExtended = { b_lu: string[] b_lu_name: string b_lu_croprotation: string + b_lu_harvestable: "once" | "multiple" | "nonde" b_lu_start: Date[] b_lu_end: Date[] fields: { @@ -35,7 +36,7 @@ export type RotationExtended = { b_isproductive: boolean a_som_loi: number b_soiltype_agr: string - harvests: any + b_lu_harvest_date: Date[] fertilizerApplications: { p_name_nl: string p_id: string @@ -117,8 +118,11 @@ export const columns: ColumnDef[] = [

) } - const firstDate = b_lu_start[0] - const lastDate = b_lu_start[b_lu_start.length - 1] + const b_lu_start_sorted = b_lu_start.sort( + (a, b) => a.getTime() - b.getTime(), + ) + const firstDate = b_lu_start_sorted[0] + const lastDate = b_lu_start_sorted[b_lu_start_sorted.length - 1] return (

{`${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}`} @@ -137,22 +141,91 @@ export const columns: ColumnDef[] = [ cell: ({ row }) => { const cultivation = row.original - const b_lu_start = cultivation.field.harvests + const b_lu_harvest_date = cultivation.fields.flatMap( + (field) => field.b_lu_harvest_date, + ) + if (b_lu_harvest_date.length === 1) { + return ( +

+ {format(b_lu_harvest_date[0], "PP", { locale: nl })} +

+ ) + } + + if ( + b_lu_harvest_date.length > 1 && + cultivation.b_lu_harvestable === "once" + ) { + const b_lu_harvest_date_sorted = b_lu_harvest_date.sort( + (a, b) => a.getTime() - b.getTime(), + ) + const firstDate = b_lu_harvest_date_sorted[0] + const lastDate = + b_lu_harvest_date_sorted[ + b_lu_harvest_date_sorted.length - 1 + ] - if (b_lu_start.length === 1) { return (

- {format(b_lu_start[0], "PP", { locale: nl })} + {`${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}`}

) } - const firstDate = b_lu_start[0] - const lastDate = b_lu_start[b_lu_start.length - 1] - return ( -

- {`${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}`} -

- ) + if ( + b_lu_harvest_date.length > 1 && + cultivation.b_lu_harvestable === "multiple" + ) { + const b_lu_harvest_date_per_field = cultivation.fields.map( + (field) => field.b_lu_harvest_date, + ) + + const harvestsByOrder: Date[][] = [] + for (const harvestDates of b_lu_harvest_date_per_field) { + const harvestDatesSorted = [...harvestDates].sort( + (a, b) => a.getTime() - b.getTime(), + ) + for (let i = 0; i < harvestDatesSorted.length; i++) { + if (!harvestsByOrder[i]) { + harvestsByOrder[i] = [] + } + harvestsByOrder[i].push(harvestDatesSorted[i]) + } + } + + return ( +
+ {harvestsByOrder.map((harvestDates, idx) => { + const harvestDatesSorted = [...harvestDates].sort( + (a, b) => a.getTime() - b.getTime(), + ) + if (harvestDatesSorted.length === 1) { + return ( +

+ {`${idx + 1}e ${cultivation.b_lu_croprotation === "grass" ? "snede" : "oogst"}: ${format( + harvestDatesSorted[0], + "PP", + { locale: nl }, + )}`} +

+ ) + } + const firstDate = harvestDatesSorted[0] + const lastDate = + harvestDatesSorted[ + harvestDatesSorted.length - 1 + ] + return ( +

+ {`${idx + 1}e ${cultivation.b_lu_croprotation === "grass" ? "snede" : "oogst"}: ${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}`} +

+ ) + })} +
+ ) + } }, }, { @@ -303,112 +376,4 @@ export const columns: ColumnDef[] = [ ) }, }, - - // { - // accessorKey: "a_som_loi", - // enableSorting: true, - // sortingFn: "alphanumeric", - // header: ({ column }) => { - // return - // }, - // enableHiding: true, // Enable hiding for mobile - // cell: ({ row }) => { - // const field = row.original - // return ( - //

- // {`${field.a_som_loi.toFixed(2)} %`} - //

- // ) - // }, - // }, - // { - // accessorKey: "b_soiltype_agr", - // enableSorting: true, - // sortingFn: "alphanumeric", - // header: ({ column }) => { - // return - // }, - // enableHiding: true, // Enable hiding for mobile - // cell: ({ row }) => { - // const field = row.original - // return ( - //

{field.b_soiltype_agr}

- // ) - // }, - // }, - // { - // accessorKey: "b_area", - // enableSorting: true, - // sortingFn: "alphanumeric", - // header: ({ column }) => { - // return - // }, - // enableHiding: true, // Enable hiding for mobile - // cell: ({ row }) => { - // const field = row.original - // return ( - //

- // {field.b_area < 0.1 - // ? "< 0.1 ha" - // : `${field.b_area.toFixed(1)} ha`} - //

- // ) - // }, - // }, - // { - // id: "actions", - // enableHiding: false, - // cell: ({ row }) => { - // const field = row.original - - // return ( - // - // - // - // - // - // {/* Acties - // - // navigator.clipboard.writeText(field.b_id) - // } - // > - // Kopieer perceel id - // - // */} - // Gegevens - // - // Overzicht - // - // - // - // Gewassen - // - // - // - // - // Bemesting - // - // - // - // Bodem - // - // - // - // Kaart - // - // - // - // - // Verwijderen - // - // - // - // - // ) - // }, - // }, ] diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx index f2b4a0569..163b74989 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx @@ -1,5 +1,6 @@ import { getCultivations, + getCultivationsFromCatalogue, getCurrentSoilData, getFarms, getFertilizerApplications, @@ -17,7 +18,10 @@ import { } from "react-router" import { FarmContent } from "~/components/blocks/farm/farm-content" import { FarmTitle } from "~/components/blocks/farm/farm-title" -import { columns, RotationExtended } from "~/components/blocks/rotation/columns" +import { + columns, + type RotationExtended, +} from "~/components/blocks/rotation/columns" import { DataTable } from "~/components/blocks/rotation/table" import { Header } from "~/components/blocks/header/base" import { HeaderFarm } from "~/components/blocks/header/farm" @@ -30,6 +34,7 @@ import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { useFieldFilterStore } from "~/store/field-filter" +import { getCultivationCatalogue } from "@svenvw/fdm-data" export const meta: MetaFunction = () => { return [ @@ -119,6 +124,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { b_id_farm, ) + const cultivationCatalogue = await getCultivationsFromCatalogue( + fdm, + session.principal_id, + b_id_farm, + ) + const fieldsExtended = await Promise.all( fields.map(async (field) => { const cultivations = await getCultivations( @@ -129,13 +140,20 @@ export async function loader({ request, params }: LoaderFunctionArgs) { ) const harvests = await Promise.all( - cultivations.map(async (cultivation) => { - return await getHarvests( + cultivations.flatMap(async (cultivation) => { + const harvests = await getHarvests( fdm, session.principal_id, cultivation.b_lu, timeframe, ) + + return { + b_lu: cultivation.b_lu, + b_lu_harvest_date: harvests.map( + (harvest) => harvest.b_lu_harvest_date, + ), + } }), ) @@ -227,25 +245,20 @@ export async function loader({ request, params }: LoaderFunctionArgs) { ), ].map((b_lu_end) => new Date(b_lu_end)) - // Get all harvests for this cultivation - const harvestsFiltered = fieldsWithThisCultivation.flatMap( - (field) => - field.harvests.filter((harvest) => - cultivationsForCatalogue.some( - (cultivation) => - cultivation.b_lu === harvest.b_lu, - ), - ), + const b_lu = cultivationsForCatalogue.map( + (cultivation) => cultivation.b_lu, ) return { b_lu_catalogue: b_lu_catalogue, - b_lu: cultivationsForCatalogue.map( - (cultivation) => cultivation.b_lu, - ), + b_lu: b_lu, b_lu_name: cultivationsForCatalogue[0]?.b_lu_name ?? "", b_lu_croprotation: cultivationsForCatalogue[0]?.b_lu_croprotation ?? "", + b_lu_harvestable: + cultivationCatalogue.find( + (x) => x.b_lu_catalogue === b_lu_catalogue, + )?.b_lu_harvestable ?? "once", b_lu_start: b_lu_start, b_lu_end: b_lu_end, fields: fieldsWithThisCultivation.map((field) => ({ @@ -255,7 +268,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) { b_isproductive: field.b_isproductive, a_som_loi: field.a_som_loi ?? 0, b_soiltype_agr: field.b_soiltype_agr ?? "", - harvests: harvestsFiltered, + b_lu_harvest_date: field.harvests.flatMap( + (harvest) => harvest.b_lu_harvest_date, + ), fertilizerApplications: field.fertilizerApplications.map((app) => ({ p_name_nl: app.p_name_nl, From ca700b8c66558384f042e91f5057026ee31025de Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:18:36 +0100 Subject: [PATCH 05/54] refactor: improve the code for rotation page --- .../components/blocks/rotation/columns.tsx | 271 +++++------------- .../blocks/rotation/fertilizer-display.tsx | 115 ++++++++ .../blocks/rotation/harvest-dates-display.tsx | 99 +++++++ ...m.$b_id_farm.$calendar.rotation._index.tsx | 110 +++---- 4 files changed, 340 insertions(+), 255 deletions(-) create mode 100644 fdm-app/app/components/blocks/rotation/fertilizer-display.tsx create mode 100644 fdm-app/app/components/blocks/rotation/harvest-dates-display.tsx diff --git a/fdm-app/app/components/blocks/rotation/columns.tsx b/fdm-app/app/components/blocks/rotation/columns.tsx index 4cd4bb0d9..d4ddbc4e2 100644 --- a/fdm-app/app/components/blocks/rotation/columns.tsx +++ b/fdm-app/app/components/blocks/rotation/columns.tsx @@ -1,3 +1,4 @@ +import React from "react" import type { ColumnDef } from "@tanstack/react-table" import { NavLink } from "react-router-dom" import { getCultivationColor } from "~/components/custom/cultivation-colors" @@ -20,6 +21,8 @@ import { TooltipContent, TooltipTrigger, } from "~/components/ui/tooltip" +import { HarvestDatesDisplay } from "./harvest-dates-display" +import { FertilizerDisplay } from "./fertilizer-display" export type RotationExtended = { b_lu_catalogue: string @@ -109,23 +112,23 @@ export const columns: ColumnDef[] = [ cell: ({ row }) => { const cultivation = row.original - const b_lu_start = cultivation.b_lu_start + const formattedDateRange = React.useMemo(() => { + const b_lu_start = cultivation.b_lu_start - if (b_lu_start.length === 1) { - return ( -

- {format(b_lu_start[0], "PP", { locale: nl })} -

+ if (b_lu_start.length === 1) { + return format(b_lu_start[0], "PP", { locale: nl }) + } + const b_lu_start_sorted = [...b_lu_start].sort( + (a, b) => a.getTime() - b.getTime(), ) - } - const b_lu_start_sorted = b_lu_start.sort( - (a, b) => a.getTime() - b.getTime(), - ) - const firstDate = b_lu_start_sorted[0] - const lastDate = b_lu_start_sorted[b_lu_start_sorted.length - 1] + const firstDate = b_lu_start_sorted[0] + const lastDate = b_lu_start_sorted[b_lu_start_sorted.length - 1] + return `${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}` + }, [cultivation.b_lu_start]) + return (

- {`${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}`} + {formattedDateRange}

) }, @@ -140,96 +143,11 @@ export const columns: ColumnDef[] = [ enableHiding: true, // Enable hiding for mobile cell: ({ row }) => { const cultivation = row.original - - const b_lu_harvest_date = cultivation.fields.flatMap( - (field) => field.b_lu_harvest_date, - ) - if (b_lu_harvest_date.length === 1) { - return ( -

- {format(b_lu_harvest_date[0], "PP", { locale: nl })} -

- ) - } - - if ( - b_lu_harvest_date.length > 1 && - cultivation.b_lu_harvestable === "once" - ) { - const b_lu_harvest_date_sorted = b_lu_harvest_date.sort( - (a, b) => a.getTime() - b.getTime(), - ) - const firstDate = b_lu_harvest_date_sorted[0] - const lastDate = - b_lu_harvest_date_sorted[ - b_lu_harvest_date_sorted.length - 1 - ] - - return ( -

- {`${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}`} -

- ) - } - if ( - b_lu_harvest_date.length > 1 && - cultivation.b_lu_harvestable === "multiple" - ) { - const b_lu_harvest_date_per_field = cultivation.fields.map( - (field) => field.b_lu_harvest_date, - ) - - const harvestsByOrder: Date[][] = [] - for (const harvestDates of b_lu_harvest_date_per_field) { - const harvestDatesSorted = [...harvestDates].sort( - (a, b) => a.getTime() - b.getTime(), - ) - for (let i = 0; i < harvestDatesSorted.length; i++) { - if (!harvestsByOrder[i]) { - harvestsByOrder[i] = [] - } - harvestsByOrder[i].push(harvestDatesSorted[i]) - } - } - - return ( -
- {harvestsByOrder.map((harvestDates, idx) => { - const harvestDatesSorted = [...harvestDates].sort( - (a, b) => a.getTime() - b.getTime(), - ) - if (harvestDatesSorted.length === 1) { - return ( -

- {`${idx + 1}e ${cultivation.b_lu_croprotation === "grass" ? "snede" : "oogst"}: ${format( - harvestDatesSorted[0], - "PP", - { locale: nl }, - )}`} -

- ) - } - const firstDate = harvestDatesSorted[0] - const lastDate = - harvestDatesSorted[ - harvestDatesSorted.length - 1 - ] - return ( -

- {`${idx + 1}e ${cultivation.b_lu_croprotation === "grass" ? "snede" : "oogst"}: ${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}`} -

- ) - })} -
- ) - } + return }, }, { - accessorKey: "fertilizerApplications", + accessorKey: "fertilizers", enableSorting: false, enableHiding: true, // Enable hiding for mobile header: ({ column }) => { @@ -238,75 +156,8 @@ export const columns: ColumnDef[] = [ ) }, cell: ({ row }) => { - const fields = row.original.fields - const fertilizers = fields.flatMap((field) => field.fertilizers) - const uniqueFertilizers = Array.from( - new Map(fertilizers.map((f) => [f.p_id, f])).values(), - ) - - return ( -
- {uniqueFertilizers.map((fertilizer) => { - const isFertilizerUsedOnAllFieldsForThisCultivation = - fields.every((field) => - field.fertilizers.some( - (f) => f.p_id === fertilizer.p_id, - ), - ) - const fertilizerIconFillShade = - isFertilizerUsedOnAllFieldsForThisCultivation - ? "600" - : "300" - - return ( - - - - - {fertilizer.p_type === "manure" ? ( - - ) : fertilizer.p_type === - "mineral" ? ( - - ) : fertilizer.p_type === - "compost" ? ( - - ) : ( - - )} - - {fertilizer.p_name_nl} - - - - {isFertilizerUsedOnAllFieldsForThisCultivation - ? "Deze meststof is toegepast op alle percelen met dit gewas" - : "Deze meststof is op sommige percelen met dit gewas toegepast"} - - - ) - })} -
- ) + const cultivation = row.original + return }, }, { @@ -319,38 +170,42 @@ export const columns: ColumnDef[] = [ cell: ({ row }) => { const cultivation = row.original - const fieldsSorted = [...cultivation.fields].sort((a, b) => - a.b_name.localeCompare(b.b_name), - ) - return ( - - - - - - -
- {fieldsSorted.map((field, idx) => ( - - - {field.b_name} - - - ))} -
-
-
-
- ) + const fieldsDisplay = React.useMemo(() => { + const fieldsSorted = [...cultivation.fields].sort((a, b) => + a.b_name.localeCompare(b.b_name), + ) + return ( + + + + + + = 8 ? "h-72 overflow-y-auto w-48" : "w-48"}> +
+ {fieldsSorted.map((field) => ( + + + {field.b_name} + + + ))} +
+
+
+
+ ) + }, [cultivation.fields]) + + return fieldsDisplay }, }, { @@ -364,14 +219,18 @@ export const columns: ColumnDef[] = [ cell: ({ row }) => { const cultivation = row.original - const b_area = cultivation.fields.reduce( - (acc, field) => acc + field.b_area, - 0, - ) + const formattedArea = React.useMemo(() => { + const b_area = cultivation.fields.reduce( + (acc, field) => acc + field.b_area, + 0, + ) + + return b_area < 0.1 ? "< 0.1 ha" : `${b_area.toFixed(1)} ha` + }, [cultivation.fields]) return (

- {b_area < 0.1 ? "< 0.1 ha" : `${b_area.toFixed(1)} ha`} + {formattedArea}

) }, diff --git a/fdm-app/app/components/blocks/rotation/fertilizer-display.tsx b/fdm-app/app/components/blocks/rotation/fertilizer-display.tsx new file mode 100644 index 000000000..708b6e799 --- /dev/null +++ b/fdm-app/app/components/blocks/rotation/fertilizer-display.tsx @@ -0,0 +1,115 @@ +import React from "react" +import { Circle, Diamond, Square, Triangle } from "lucide-react" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip" +import { Badge } from "~/components/ui/badge" +import type { RotationExtended } from "./columns" + +type FertilizerDisplayProps = { + cultivation: RotationExtended +} + +const fertilizerIconClassMap = { + manure: { + text: "text-yellow-600", + fill: { + "600": "fill-yellow-600", + "300": "fill-yellow-300", + }, + }, + mineral: { + text: "text-sky-600", + fill: { + "600": "fill-sky-600", + "300": "fill-sky-300", + }, + }, + compost: { + text: "text-green-600", + fill: { + "600": "fill-green-600", + "300": "fill-green-300", + }, + }, + other: { + text: "text-gray-600", + fill: { + "600": "fill-gray-600", + "300": "fill-gray-300", + }, + }, +} as const; + +export const FertilizerDisplay: React.FC = ({ cultivation }) => { + const uniqueFertilizers = React.useMemo(() => { + const fields = cultivation.fields + const fertilizers = fields.flatMap((field) => field.fertilizers) + return Array.from( + new Map(fertilizers.map((f) => [f.p_id, f])).values(), + ) + }, [cultivation.fields]) + + const fertilizerDisplay = React.useMemo(() => { + const fields = cultivation.fields + return ( +
+ {uniqueFertilizers.map((fertilizer) => { + const isFertilizerUsedOnAllFieldsForThisCultivation = + fields.every((field) => + field.fertilizers.some( + (f) => f.p_id === fertilizer.p_id, + ), + ) + const fertilizerIconFillShade = + isFertilizerUsedOnAllFieldsForThisCultivation + ? "600" + : "300" + + return ( + + + + + {fertilizer.p_type === "manure" ? ( + + ) : fertilizer.p_type === + "mineral" ? ( + + ) : fertilizer.p_type === + "compost" ? ( + + ) : ( + + )} + + {fertilizer.p_name_nl} + + + + {isFertilizerUsedOnAllFieldsForThisCultivation + ? "Deze meststof is toegepast op alle percelen met dit gewas" + : "Deze meststof is op sommige percelen met dit gewas toegepast"} + + + ) + })} +
+ ) + }, [uniqueFertilizers, cultivation.fields]) + + return fertilizerDisplay +} diff --git a/fdm-app/app/components/blocks/rotation/harvest-dates-display.tsx b/fdm-app/app/components/blocks/rotation/harvest-dates-display.tsx new file mode 100644 index 000000000..1b4618e37 --- /dev/null +++ b/fdm-app/app/components/blocks/rotation/harvest-dates-display.tsx @@ -0,0 +1,99 @@ +import React from "react" +import { format } from "date-fns" +import { nl } from "date-fns/locale/nl" +import type { RotationExtended } from "./columns" + +type HarvestDatesDisplayProps = { + cultivation: RotationExtended +} + +export const HarvestDatesDisplay: React.FC = ({ cultivation }) => { + const formattedHarvestDates = React.useMemo(() => { + const b_lu_harvest_date = cultivation.fields.flatMap( + (field) => field.b_lu_harvest_date, + ) + if (b_lu_harvest_date.length === 1) { + return ( +

+ {format(b_lu_harvest_date[0], "PP", { locale: nl })} +

+ ) + } + + if ( + b_lu_harvest_date.length > 1 && + cultivation.b_lu_harvestable === "once" + ) { + const b_lu_harvest_date_sorted = [...b_lu_harvest_date].sort( + (a, b) => a.getTime() - b.getTime(), + ) + const firstDate = b_lu_harvest_date_sorted[0] + const lastDate = + b_lu_harvest_date_sorted[ + b_lu_harvest_date_sorted.length - 1 + ] + + return ( +

+ {`${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}`} +

+ ) + } + if ( + b_lu_harvest_date.length > 1 && + cultivation.b_lu_harvestable === "multiple" + ) { + const b_lu_harvest_date_per_field = cultivation.fields.map( + (field) => field.b_lu_harvest_date, + ) + + const harvestsByOrder: Date[][] = [] + for (const harvestDates of b_lu_harvest_date_per_field) { + const harvestDatesSorted = [...harvestDates].sort( + (a, b) => a.getTime() - b.getTime(), + ) + for (let i = 0; i < harvestDatesSorted.length; i++) { + if (!harvestsByOrder[i]) { + harvestsByOrder[i] = [] + } + harvestsByOrder[i].push(harvestDatesSorted[i]) + } + } + + return ( +
+ {harvestsByOrder.map((harvestDates, idx) => { + // harvestDates are already sorted from the previous loop + if (harvestDates.length === 1) { + return ( +

+ {`${idx + 1}e ${cultivation.b_lu_croprotation === "grass" ? "snede" : "oogst"}: ${format( + harvestDates[0], + "PP", + { locale: nl }, + )}`} +

+ ) + } + const firstDate = harvestDates[0] + const lastDate = + harvestDates[ + harvestDates.length - 1 + ] + return ( +

+ {`${idx + 1}e ${cultivation.b_lu_croprotation === "grass" ? "snede" : "oogst"}: ${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}`} +

+ ) + })} +
+ ) + } + return null; // Should not happen + }, [cultivation.fields, cultivation.b_lu_harvestable, cultivation.b_lu_croprotation]) + + return formattedHarvestDates +} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx index 163b74989..82f01a158 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx @@ -1,4 +1,5 @@ import { + CultivationCatalogue, getCultivations, getCultivationsFromCatalogue, getCurrentSoilData, @@ -69,9 +70,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // Get the active farm const b_id_farm = params.b_id_farm if (!b_id_farm) { - throw data("missing: b_id_farm", { - status: 400, - statusText: "missing: b_id_farm", + throw new Response("Not Found", { + status: 404, + statusText: "Not Found", }) } @@ -164,11 +165,13 @@ export async function loader({ request, params }: LoaderFunctionArgs) { timeframe, ) - const fertilizersFiltered = fertilizers.filter((fertilizer) => { - return fertilizerApplications.some((application) => { - return application.p_id === fertilizer.p_id - }) - }) + const fertilizerApplicationIds = new Set( + fertilizerApplications.map((app) => app.p_id), + ) + + const fertilizersFiltered = fertilizers.filter((fertilizer) => + fertilizerApplicationIds.has(fertilizer.p_id), + ) const currentSoilData = await getCurrentSoilData( fdm, @@ -177,11 +180,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { timeframe, ) const a_som_loi = - currentSoilData.find((x) => x.parameter === "a_som_loi") - ?.value ?? null + currentSoilData.find( + (item: { parameter: string }) => item.parameter === "a_som_loi", + )?.value ?? null const b_soiltype_agr = currentSoilData.find( - (x) => x.parameter === "b_soiltype_agr", + (item: { parameter: string }) => item.parameter === "b_soiltype_agr", )?.value ?? null return { @@ -199,23 +203,25 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }), ) - // Transform fieldsExtended to rotationExtended - const cultivationsInRotation: string[] = [ - ...new Set( - fieldsExtended.flatMap((field) => { - return field.cultivations.flatMap((cultivation) => { - return cultivation.b_lu_catalogue - }) - }), - ), - ] - - const rotationExtended: RotationExtended[] = cultivationsInRotation.map( - (b_lu_catalogue) => { + const transformFieldsToRotationExtended = ( + fieldsExtended: any[], // TODO: Define a proper type for fieldsExtended + cultivationCatalogue: CultivationCatalogue, + ): RotationExtended[] => { + const cultivationsInRotation: string[] = [ + ...new Set( + fieldsExtended.flatMap((field: { cultivations: { b_lu_catalogue: string }[] }) => { + return field.cultivations.flatMap((cultivation) => { + return cultivation.b_lu_catalogue + }) + }), + ), + ] + + return cultivationsInRotation.map((b_lu_catalogue) => { const cultivationsForCatalogue = fieldsExtended.flatMap( (field) => field.cultivations.filter( - (cultivation) => + (cultivation: { b_lu_catalogue: string }) => cultivation.b_lu_catalogue === b_lu_catalogue, ), ) @@ -223,7 +229,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const fieldsWithThisCultivation = fieldsExtended.filter( (field) => field.cultivations.some( - (cultivation) => + (cultivation: { b_lu_catalogue: string }) => cultivation.b_lu_catalogue === b_lu_catalogue, ), ) @@ -231,22 +237,22 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // Get all unique b_lu_start of cultivation const b_lu_start = [ ...new Set( - cultivationsForCatalogue.map((cultivation) => + cultivationsForCatalogue.map((cultivation: { b_lu_start: Date }) => cultivation.b_lu_start.getTime(), ), ), - ].map((b_lu_start) => new Date(b_lu_start)) + ].map((timestamp) => new Date(timestamp)) const b_lu_end = [ ...new Set( - cultivationsForCatalogue.map((cultivation) => - cultivation.b_lu_start.getTime(), - ), + cultivationsForCatalogue + .filter((cultivation: { b_lu_end: Date | null }) => cultivation.b_lu_end) + .map((cultivation: { b_lu_end: Date }) => cultivation.b_lu_end.getTime()), ), - ].map((b_lu_end) => new Date(b_lu_end)) + ].map((timestamp) => new Date(timestamp)) const b_lu = cultivationsForCatalogue.map( - (cultivation) => cultivation.b_lu, + (cultivation: { b_lu: string }) => cultivation.b_lu, ) return { @@ -257,11 +263,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) { cultivationsForCatalogue[0]?.b_lu_croprotation ?? "", b_lu_harvestable: cultivationCatalogue.find( - (x) => x.b_lu_catalogue === b_lu_catalogue, + (item: { b_lu_catalogue: string }) => item.b_lu_catalogue === b_lu_catalogue, )?.b_lu_harvestable ?? "once", b_lu_start: b_lu_start, b_lu_end: b_lu_end, - fields: fieldsWithThisCultivation.map((field) => ({ + fields: fieldsWithThisCultivation.map((field: any) => ({ // TODO: Define a proper type for field b_id: field.b_id, b_name: field.b_name, b_area: field.b_area, @@ -269,30 +275,44 @@ export async function loader({ request, params }: LoaderFunctionArgs) { a_som_loi: field.a_som_loi ?? 0, b_soiltype_agr: field.b_soiltype_agr ?? "", b_lu_harvest_date: field.harvests.flatMap( - (harvest) => harvest.b_lu_harvest_date, + (harvest: { b_lu_harvest_date: Date[] }) => harvest.b_lu_harvest_date, ), fertilizerApplications: - field.fertilizerApplications.map((app) => ({ + field.fertilizerApplications.map((app: { p_name_nl: string; p_id: string; p_type: string }) => ({ p_name_nl: app.p_name_nl, p_id: app.p_id, p_type: app.p_type, })), - fertilizers: field.fertilizers.map((app) => ({ + fertilizers: field.fertilizers.map((app: { p_name_nl: string; p_id: string; p_type: string }) => ({ p_name_nl: app.p_name_nl, p_id: app.p_id, p_type: app.p_type, })), })), } - }, + }) + } + + const rotationExtended: RotationExtended[] = transformFieldsToRotationExtended( + fieldsExtended, + cultivationCatalogue, ) + const { showProductiveOnly } = useFieldFilterStore.getState() // Get state directly in loader + + const filteredRotations = rotationExtended.filter((rotation) => { + if (!showProductiveOnly) { + return true + } + return rotation.fields.some((field) => field.b_isproductive) + }) + // Return user information from loader return { b_id_farm: b_id_farm, farmOptions: farmOptions, fieldOptions: fieldOptions, - rotationExtended: rotationExtended, + rotationExtended: filteredRotations, // Return filtered data userName: session.userName, } } catch (error) { @@ -313,14 +333,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) { */ export default function FarmRotationIndex() { const loaderData = useLoaderData() - const { showProductiveOnly } = useFieldFilterStore() - - const filteredRotations = loaderData.rotationExtended.filter((rotation) => { - if (!showProductiveOnly) { - return true - } - return rotation.fields.some((field) => field.b_isproductive) - }) const currentFarmName = loaderData.farmOptions.find( @@ -379,7 +391,7 @@ export default function FarmRotationIndex() {
From 4c0f3c354d54a60f5493ff078832b55b266251b1 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:27:04 +0100 Subject: [PATCH 06/54] refactor: improve search bar --- .../app/components/blocks/rotation/table.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/fdm-app/app/components/blocks/rotation/table.tsx b/fdm-app/app/components/blocks/rotation/table.tsx index f918418ec..2177a1f6f 100644 --- a/fdm-app/app/components/blocks/rotation/table.tsx +++ b/fdm-app/app/components/blocks/rotation/table.tsx @@ -6,6 +6,7 @@ import { getCoreRowModel, getFilteredRowModel, getSortedRowModel, + getFacetedRowModel, type Row, type RowSelectionState, type SortingState, @@ -121,14 +122,27 @@ export function DataTable({ ...item, searchTarget: `${item.b_lu_name} ${[ ...new Set( - item.b_lu_start.map((date) => - format(date, "dd MMMM yyy", { locale: nl }), + item.b_lu_start.map((date: Date) => + format(date, "d MMMM yyy", { locale: nl }), + ), + ), + ].join(" ")} ${[ + ...new Set( + item.fields.flatMap((field) => + field.b_lu_harvest_date.map((date: Date) => + format(date, "d MMMM yyy", { locale: nl }), + ), + ), + ), + ].join(" ")} ${[ + ...new Set( + item.fields.flatMap((field) => + field.fertilizers.map((fertilizer) => fertilizer.p_name_nl), ), ), ].join(" ")}`, })) }, [data]) - // console.log(memoizedData.searchTarget) const fuzzyFilter: FilterFn = (row, _columnId, filterValue) => { const result = fuzzysort.go(filterValue, [ @@ -145,6 +159,7 @@ export function DataTable({ getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), onColumnVisibilityChange: setColumnVisibility, onGlobalFilterChange: setGlobalFilter, onRowSelectionChange: setRowSelection, @@ -183,7 +198,7 @@ export function DataTable({
setGlobalFilter(event.target.value)} className="w-full sm:w-auto sm:grow" From 5f05efdbaa975ef75825fba32285671c9493bcf9 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:22:36 +0100 Subject: [PATCH 07/54] feat: add page for adding fertilizer to multiple fields based on cultivation --- .../blocks/fertilizer-applications/form.tsx | 11 +- ...m.$calendar.rotation.fertilizer._index.tsx | 711 ++++++++++++++++++ 2 files changed, 719 insertions(+), 3 deletions(-) create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx diff --git a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx index 2f4e939f8..6019eba49 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx @@ -37,7 +37,12 @@ import { FormSchema, FormSchemaModify, } from "./formschema" -import type { FertilizerOption } from "./types.d" + +export type FertilizerOption = { + value: string + label: string + applicationMethodOptions?: { value: string; label: string }[] +} export function FertilizerApplicationForm({ options, @@ -150,12 +155,12 @@ export function FertilizerApplicationForm({ navigate( searchParams.has("fieldIds") ? `./manage/new?fieldIds=${searchParams.get("fieldIds")}` - : "./manage/new", + : `./manage/new?cultivationIds=${searchParams.get("cultivationIds")}`, ) } return ( - +
{ + return [ + { title: `Bemesting toevoegen | ${clientConfig.name}` }, + { + name: "description", + content: "", + }, + ] +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + // Get the active farm + const b_id_farm = params.b_id_farm + if (!b_id_farm) { + throw data("missing: b_id_farm", { + status: 400, + statusText: "missing: b_id_farm", + }) + } + + // Get cultivationIds from search params + const url = new URL(request.url) + const cultivationIds = + url.searchParams + .get("cultivationIds") + ?.split(",") + .filter(Boolean) ?? [] + + // Get the session + const session = await getSession(request) + + // Get timeframe from calendar store + const timeframe = getTimeframe(params) + const calendar = getCalendar(params) + + // Get a list of possible farms of the user + const farms = await getFarms(fdm, session.principal_id) + + // Redirect to farms overview if user has no farm + if (farms.length === 0) { + return redirect("./farm") + } + + // Get farms to be selected + const farmOptions = farms.map((farm) => { + if (!farm?.b_id_farm || !farm?.b_name_farm) { + throw new Error("Invalid farm data structure") + } + return { + b_id_farm: farm.b_id_farm, + b_name_farm: farm.b_name_farm, + } + }) + + // Get all fields for the farm and their cultivations + const allFieldsWithCultivations = await Promise.all( + ( + await getFields(fdm, session.principal_id, b_id_farm, timeframe) + ).map(async (field) => { + const cultivations = await getCultivations( + fdm, + session.principal_id, + field.b_id, + timeframe, + ) + return { + ...field, + cultivations: cultivations.map((c) => c.b_lu_catalogue), + } + }), + ) + + // Get fieldIds from search params (if any) + const fieldIdsFromSearchParams = + url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? [] + + // Filter fields based on cultivationIds or fieldIdsFromSearchParams + let selectedFields = [] + let cultivationName = "" + let cultivationCatalogueData = [] + + if (cultivationIds.length > 0) { + cultivationCatalogueData = await getCultivationsFromCatalogue( + fdm, + session.principal_id, + b_id_farm, + ) + + const targetCultivation = cultivationCatalogueData.find( + (c: { b_lu_catalogue: string }) => + c.b_lu_catalogue === cultivationIds[0], + ) + + if (targetCultivation) { + cultivationName = targetCultivation.b_lu_name + } + + if (fieldIdsFromSearchParams.length > 0) { + // If fieldIds are in search params, use them to determine selected fields + selectedFields = allFieldsWithCultivations.filter((field) => + fieldIdsFromSearchParams.includes(field.b_id!), + ) + } else { + // Otherwise, default to fields with the selected cultivation + selectedFields = allFieldsWithCultivations.filter((field) => + field.cultivations.some((c) => cultivationIds.includes(c)), + ) + } + } else { + throw data("missing: cultivationIds", { + status: 400, + statusText: "missing: cultivationIds", + }) + } + + const fieldOptions = allFieldsWithCultivations.map((field) => { + if (!field?.b_id || !field?.b_name) { + throw new Error("Invalid field data structure") + } + return { + b_id: field.b_id, + b_name: field.b_name, + b_area: Math.round(field.b_area * 10) / 10, + cultivations: field.cultivations, // Pass cultivations for each field + } + }) + + // Get available fertilizers for the farm + const fertilizers = await getFertilizers( + fdm, + session.principal_id, + b_id_farm, + ) + const fertilizerParameterDescription = + getFertilizerParametersDescription() + const applicationMethods = fertilizerParameterDescription.find( + (x: { parameter: string }) => + x.parameter === "p_app_method_options", + ) + if (!applicationMethods) throw new Error("Parameter metadata missing") + // Map fertilizers to options for the combobox + const fertilizerOptions: FertilizerOption[] = fertilizers.map( + (fertilizer) => { + const applicationMethodOptions = fertilizer.p_app_method_options + .map((opt: string) => { + const meta = applicationMethods.options.find( + (x: { value: string }) => x.value === opt, + ) + return meta + ? { value: opt, label: meta.label } + : undefined + }) + .filter( + (option: { + value: string + label: string + }): option is { value: string; label: string } => + option !== undefined, + ) + return { + value: fertilizer.p_id, + label: fertilizer.p_name_nl, + applicationMethodOptions: applicationMethodOptions, + } + }, + ) + + // Return user information from loader + return { + b_id_farm: b_id_farm, + farmOptions: farmOptions, + fieldAmount: selectedFields.length, + fertilizerOptions: fertilizerOptions, + calendar: calendar, + selectedFields: selectedFields.map( + (field: { + b_id: string + b_name: string + b_area: number + cultivations: string[] + }) => ({ + b_id: field.b_id, + b_name: field.b_name, + b_area: Math.round(field.b_area * 10) / 10, + cultivations: field.cultivations, + }), + ), + fieldOptions: fieldOptions.map( + (field: { + b_id: string + b_name: string + b_area: number + cultivations: string[] + }) => ({ + b_id: field.b_id, + b_name: field.b_name, + b_area: field.b_area, + cultivations: field.cultivations, + }), + ), // All fields for selection + cultivationName: cultivationName, + cultivationIds: cultivationIds, + fertilizerApplication: { + // Dummy data for now + p_id: "", + p_app_amount: 0, + p_app_method: "", + p_app_date: new Date(), + }, + } + } catch (error) { + throw handleLoaderError(error) + } +} + +export default function FarmRotationFertilizerAddIndex() { + const loaderData = useLoaderData() + const navigation = useNavigation() + const location = useLocation() + const [searchParams, setSearchParams] = useSearchParams() + const [open, setOpen] = useState(false) + const [selectedFieldIds, setSelectedFieldIds] = useState( + loaderData.selectedFields.map((field) => field.b_id!), + ) + + useEffect(() => { + setSelectedFieldIds( + loaderData.selectedFields.map((field) => field.b_id!), + ) + }, [loaderData.selectedFields]) + + const isSubmitting = navigation.state === "submitting" + + const handleSelectionChange = () => { + const newSearchParams = new URLSearchParams(searchParams) + newSearchParams.set("fieldIds", selectedFieldIds.join(",")) + newSearchParams.set( + "cultivationIds", + loaderData.cultivationIds.join(","), + ) + setSearchParams(newSearchParams, { preventScrollReset: true }) + setOpen(false) + } + + const isSelected = (fieldId: string) => selectedFieldIds.includes(fieldId) + + const toggleSelection = (fieldId: string) => { + setSelectedFieldIds((prev) => + isSelected(fieldId) + ? prev.filter((id) => id !== fieldId) + : [...prev, fieldId], + ) + } + + const displayedSelectedFields = loaderData.fieldOptions.filter((field) => + selectedFieldIds.includes(field.b_id!), + ) + + return ( + +
+ + + + Bouwplan + + + + Bemesting toevoegen + +
+
+ +
+ {isSubmitting && ( +
+
+ + Bemesting wordt toegevoegd... +
+
+ )} + +
+ + + + Geselecteerde percelen + + + De bemesting wordt toegepast op de + volgende percelen. + + + + {displayedSelectedFields.length > 0 ? ( +
+ {displayedSelectedFields.map( + (field) => ( +
+

+ {field.b_name} +

+
+ {!field.cultivations.some( + (c) => + loaderData.cultivationIds.includes( + c, + ), + ) && ( + + + + + + +

+ Dit + perceel + heeft + het + geselecteerde + gewas + niet +

+
+
+
+ )} + + {field.b_area}{" "} + ha + +
+
+ ), + )} +
+ ) : ( +
+ +

+ Geen percelen geselecteerd +

+

+ Pas uw selectie aan, of ga naar + het percelenoverzicht voor meer + filtermogelijkheden. +

+ +
+ )} +
+ + + + + + + + + Percelen selecteren + + + Selecteer de percelen voor + de bemesting. + + +
+
+ {loaderData.fieldOptions + .filter((field) => + field.cultivations.some( + (c) => + loaderData.cultivationIds.includes( + c, + ), + ), + ) + .map((field) => ( +
+ + toggleSelection( + field.b_id!, + ) + } + /> + + + { + field.b_area + }{" "} + ha + +
+ ))} +
+
+ + + + + Percelen + zonder{" "} + { + loaderData.cultivationName + } + + + +
+ {loaderData.fieldOptions + .filter( + ( + field, + ) => + !field.cultivations.some( + ( + c, + ) => + loaderData.cultivationIds.includes( + c, + ), + ), + ) + .map( + ( + field, + ) => ( +
+ + toggleSelection( + field.b_id!, + ) + } + /> + + + { + field.b_area + }{" "} + ha + +
+ ), + )} +
+
+
+
+
+
+ + + +
+
+
+
+ + + Bemesting toevoegen + + {loaderData.fieldAmount === 0 + ? "Selecteer eerst een of meerdere percelen." + : loaderData.fieldAmount === 1 + ? "Voeg een nieuwe bemestingstoepassing toe aan het geselecteerde perceel." + : `Voeg een nieuwe bemestingstoepassing toe aan de ${loaderData.fieldAmount} geselecteerde percelen.`} + + + + {loaderData.fieldAmount > 0 ? ( + + ) : ( +
+

+ Selecteer eerst percelen in de + linkerkolom. +

+
+ )} +
+
+
+
+
+
+
+ ) +} + +export async function action({ request, params }: ActionFunctionArgs) { + try { + const { b_id_farm, calendar = "all" } = params + if (!b_id_farm) { + throw new Error("Farm ID is missing") + } + + const session = await getSession(request) + const url = new URL(request.url) + const fieldIds = + url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? [] + + if (!fieldIds || fieldIds.length === 0) { + return dataWithError(null, "Selecteer eerst een perceel.") + } + + const validatedData = await extractFormValuesFromRequest( + request, + FormSchema, + ) + + for (const fieldId of fieldIds) { + await addFertilizerApplication( + fdm, + session.principal_id, + fieldId, + validatedData.p_id, + validatedData.p_app_amount, + validatedData.p_app_method, + validatedData.p_app_date, + ) + } + + return redirectWithSuccess(`/farm/${b_id_farm}/${calendar}/rotation`, { + message: `Bemesting succesvol toegevoegd aan ${fieldIds.length} ${fieldIds.length === 1 ? "perceel" : "percelen"}.`, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return dataWithError( + null, + "Invoer is ongeldig. Controleer het formulier.", + ) + } + throw handleActionError(error) + } +} From 1dc9d50928e9dd9673b0ecc717acac01b9683321 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:44:37 +0100 Subject: [PATCH 08/54] feat: add page to add harvest to mutliple fields based on cultivation --- .../app/components/blocks/harvest/form.tsx | 7 +- .../app/components/blocks/harvest/schema.ts | 30 + .../app/components/blocks/rotation/table.tsx | 4 +- ...farm.$calendar.rotation.harvest._index.tsx | 739 ++++++++++++++++++ 4 files changed, 776 insertions(+), 4 deletions(-) create mode 100644 fdm-app/app/components/blocks/harvest/schema.ts create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx diff --git a/fdm-app/app/components/blocks/harvest/form.tsx b/fdm-app/app/components/blocks/harvest/form.tsx index df72a0c59..edd192b75 100644 --- a/fdm-app/app/components/blocks/harvest/form.tsx +++ b/fdm-app/app/components/blocks/harvest/form.tsx @@ -23,13 +23,15 @@ export function HarvestForm({ b_lu_start, b_lu_end, b_lu_harvestable, + action, }: { b_lu_yield: number | undefined b_lu_n_harvestable: number | undefined b_lu_harvest_date: Date | undefined - b_lu_start: Date | undefined - b_lu_end: Date | undefined + b_lu_start: Date | null | undefined + b_lu_end: Date | null | undefined b_lu_harvestable: "once" | "multiple" | "none" | undefined + action: string }) { const fetcher = useFetcher() @@ -63,6 +65,7 @@ export function HarvestForm({ id="formHarvest" onSubmit={form.handleSubmit} method="post" + action={action} >
{ + if (typeof arg == "string" || arg instanceof Date) + return new Date(arg) + }, + z.date({ + required_error: "Een oogstdatum is verplicht.", + }), + ), + b_lu_yield: z.coerce.number().optional(), + b_lu_n_harvestable: z.coerce.number().optional(), + b_lu_start: z.preprocess( + (arg) => { + if (typeof arg == "string" || arg instanceof Date) + return new Date(arg) + }, + z.date().nullable().optional(), + ), + b_lu_end: z.preprocess( + (arg) => { + if (typeof arg == "string" || arg instanceof Date) + return new Date(arg) + }, + z.date().nullable().optional(), + ), + b_lu_harvestable: z.enum(["once", "multiple", "none"]).optional(), +}) diff --git a/fdm-app/app/components/blocks/rotation/table.tsx b/fdm-app/app/components/blocks/rotation/table.tsx index 2177a1f6f..37c7db24e 100644 --- a/fdm-app/app/components/blocks/rotation/table.tsx +++ b/fdm-app/app/components/blocks/rotation/table.tsx @@ -284,7 +284,7 @@ export function DataTable({ } > - Oogst + Oogst toevoegen ) : ( ({ > )} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx new file mode 100644 index 000000000..7c7299fea --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -0,0 +1,739 @@ +import { + addHarvest, + getCultivations, + getCultivationsFromCatalogue, + getFarms, + getFields, + getHarvests, + removeHarvest, +} from "@svenvw/fdm-core" +import { Info, AlertTriangle, ChevronDown } from "lucide-react" +import { useEffect, useState } from "react" +import { + type ActionFunctionArgs, + data, + type LoaderFunctionArgs, + type MetaFunction, + NavLink, + redirect, + useLoaderData, + useLocation, + useNavigation, + useSearchParams, +} from "react-router" +import { dataWithError, redirectWithSuccess } from "remix-toast" +import { z } from "zod" +import { FarmContent } from "~/components/blocks/farm/farm-content" +import { FarmTitle } from "~/components/blocks/farm/farm-title" +import { HarvestForm } from "~/components/blocks/harvest/form" +import { FormSchema } from "~/components/blocks/harvest/schema" +import { Header } from "~/components/blocks/header/base" +import { HeaderFarm } from "~/components/blocks/header/farm" +import { LoadingSpinner } from "~/components/custom/loadingspinner" +import { Badge } from "~/components/ui/badge" +import { BreadcrumbItem, BreadcrumbSeparator } from "~/components/ui/breadcrumb" +import { Button } from "~/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { Checkbox } from "~/components/ui/checkbox" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "~/components/ui/accordion" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog" +import { Label } from "~/components/ui/label" +import { SidebarInset } from "~/components/ui/sidebar" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/ui/tooltip" +import { getSession } from "~/lib/auth.server" +import { getCalendar, getTimeframe } from "~/lib/calendar" +import { clientConfig } from "~/lib/config" +import { handleActionError, handleLoaderError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" +import { extractFormValuesFromRequest } from "~/lib/form" + +export const meta: MetaFunction = () => { + return [ + { title: `Oogst toevoegen | ${clientConfig.name}` }, + { + name: "description", + content: "", + }, + ] +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + // Get the active farm + const b_id_farm = params.b_id_farm + if (!b_id_farm) { + throw data("missing: b_id_farm", { + status: 400, + statusText: "missing: b_id_farm", + }) + } + + // Get cultivationIds from search params + const url = new URL(request.url) + const cultivationIds = + url.searchParams + .get("cultivationIds") + ?.split(",") + .filter(Boolean) ?? [] + + // Ensure only one cultivationId is selected + if (cultivationIds.length !== 1) { + throw data("invalid: cultivationIds", { + status: 400, + statusText: + "Selecteer precies één gewas om oogst toe te voegen.", + }) + } + + // Get the session + const session = await getSession(request) + + // Get timeframe from calendar store + const timeframe = getTimeframe(params) + const calendar = getCalendar(params) + + // Get a list of possible farms of the user + const farms = await getFarms(fdm, session.principal_id) + + // Redirect to farms overview if user has no farm + if (farms.length === 0) { + return redirect("./farm") + } + + // Get farms to be selected + const farmOptions = farms.map((farm) => { + if (!farm?.b_id_farm || !farm?.b_name_farm) { + throw new Error("Invalid farm data structure") + } + return { + b_id_farm: farm.b_id_farm, + b_name_farm: farm.b_name_farm, + } + }) + + // Get all fields for the farm and their cultivations + const allFieldsWithCultivations = await Promise.all( + ( + await getFields(fdm, session.principal_id, b_id_farm, timeframe) + ).map(async (field) => { + const cultivations = await getCultivations( + fdm, + session.principal_id, + field.b_id, + timeframe, + ) + return { + ...field, + cultivations: cultivations.map( + (c: { b_lu_catalogue: string }) => c.b_lu_catalogue, + ), + } + }), + ) + + // Get fieldIds from search params (if any) + const fieldIdsFromSearchParams = + url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? [] + + // Filter fields based on cultivationIds or fieldIdsFromSearchParams + let selectedFields = [] + let cultivationName = "" + let cultivationCatalogueData = [] + let b_lu_harvestable: "once" | "multiple" | "none" = "none" + + if (cultivationIds.length > 0) { + cultivationCatalogueData = await getCultivationsFromCatalogue( + fdm, + session.principal_id, + b_id_farm, + ) + + const targetCultivation = cultivationCatalogueData.find( + (c: { + b_lu_catalogue: string + b_lu_harvestable: "once" | "multiple" | "none" + }) => c.b_lu_catalogue === cultivationIds[0], + ) + + if (targetCultivation) { + cultivationName = targetCultivation.b_lu_name + b_lu_harvestable = targetCultivation.b_lu_harvestable + } + + if (fieldIdsFromSearchParams.length > 0) { + // If fieldIds are in search params, use them to determine selected fields + selectedFields = allFieldsWithCultivations.filter((field) => + fieldIdsFromSearchParams.includes(field.b_id!), + ) + } else { + // Otherwise, default to fields with the selected cultivation + selectedFields = allFieldsWithCultivations.filter((field) => + field.cultivations.some((c) => cultivationIds.includes(c)), + ) + } + } else { + throw data("missing: cultivationIds", { + status: 400, + statusText: "missing: cultivationIds", + }) + } + + const fieldOptions = allFieldsWithCultivations.map((field) => { + if (!field?.b_id || !field?.b_name) { + throw new Error("Invalid field data structure") + } + return { + b_id: field.b_id, + b_name: field.b_name, + b_area: Math.round(field.b_area * 10) / 10, + cultivations: field.cultivations, // Pass cultivations for each field + } + }) + + // Return user information from loader + return { + b_id_farm: b_id_farm, + farmOptions: farmOptions, + fieldAmount: selectedFields.length, + calendar: calendar, + selectedFields: selectedFields.map( + (field: { + b_id: string + b_name: string + b_area: number + cultivations: string[] + }) => ({ + b_id: field.b_id, + b_name: field.b_name, + b_area: Math.round(field.b_area * 10) / 10, + cultivations: field.cultivations, + }), + ), + fieldOptions: fieldOptions.map( + (field: { + b_id: string + b_name: string + b_area: number + cultivations: string[] + }) => ({ + b_id: field.b_id, + b_name: field.b_name, + b_area: field.b_area, + cultivations: field.cultivations, + }), + ), // All fields for selection + cultivationName: cultivationName, + cultivationIds: cultivationIds, + b_lu_harvestable: b_lu_harvestable, + harvestApplication: { + b_lu_yield: undefined, + b_lu_n_harvestable: undefined, + b_lu_harvest_date: undefined, + b_lu_start: undefined, + b_lu_end: undefined, + b_lu_harvestable: undefined, + }, + } + } catch (error) { + throw handleLoaderError(error) + } +} + +export default function FarmRotationHarvestAddIndex() { + const loaderData = useLoaderData() + const navigation = useNavigation() + const location = useLocation() + const [searchParams, setSearchParams] = useSearchParams() + const [open, setOpen] = useState(false) + const [selectedFieldIds, setSelectedFieldIds] = useState( + loaderData.selectedFields.map((field) => field.b_id!), + ) + const [showOverwriteWarning, setShowOverwriteWarning] = useState(false) + + useEffect(() => { + setSelectedFieldIds( + loaderData.selectedFields.map((field) => field.b_id!), + ) + }, [loaderData.selectedFields]) + + const isSubmitting = navigation.state === "submitting" + + const handleSelectionChange = () => { + const newSearchParams = new URLSearchParams(searchParams) + newSearchParams.set("fieldIds", selectedFieldIds.join(",")) + newSearchParams.set( + "cultivationIds", + loaderData.cultivationIds.join(","), + ) + setSearchParams(newSearchParams, { preventScrollReset: true }) + setOpen(false) + } + + const isSelected = (fieldId: string) => selectedFieldIds.includes(fieldId) + + const toggleSelection = (fieldId: string) => { + setSelectedFieldIds((prev) => + isSelected(fieldId) + ? prev.filter((id) => id !== fieldId) + : [...prev, fieldId], + ) + } + + const displayedSelectedFields = loaderData.fieldOptions.filter((field) => + selectedFieldIds.includes(field.b_id!), + ) + + return ( + +
+ + + + Bouwplan + + + + Oogst toevoegen + +
+
+ +
+ {isSubmitting && ( +
+
+ + Oogst wordt toegevoegd... +
+
+ )} + +
+ + + + Geselecteerde percelen + + + De oogst wordt toegepast op de volgende + percelen. + + + + {displayedSelectedFields.length > 0 ? ( +
+ {displayedSelectedFields.map( + (field) => ( +
+

+ {field.b_name} +

+
+ {!field.cultivations.some( + (c) => + loaderData.cultivationIds.includes( + c, + ), + ) && ( + + + + + + +

+ Dit + perceel + heeft + het + geselecteerde + gewas + niet +

+
+
+
+ )} + + {field.b_area}{" "} + ha + +
+
+ ), + )} +
+ ) : ( +
+ +

+ Geen percelen geselecteerd +

+

+ Pas uw selectie aan, of ga naar + het percelenoverzicht voor meer + filtermogelijkheden. +

+ +
+ )} +
+ + + + + + + + + Percelen selecteren + + + Selecteer de percelen voor + de oogst. + + +
+
+ {loaderData.fieldOptions + .filter((field) => + field.cultivations.some( + (c) => + loaderData.cultivationIds.includes( + c, + ), + ), + ) + .map((field) => ( +
+ + toggleSelection( + field.b_id!, + ) + } + /> + + + { + field.b_area + }{" "} + ha + +
+ ))} +
+
+ + + +
+
+
+
+ + + Oogst toevoegen + + {loaderData.fieldAmount === 0 + ? "Selecteer eerst een of meerdere percelen." + : loaderData.fieldAmount === 1 + ? "Voeg een nieuwe oogst toe aan het geselecteerde perceel." + : `Voeg een nieuwe oogst toe aan de ${loaderData.fieldAmount} geselecteerde percelen.`} + + + + {loaderData.b_lu_harvestable === "none" ? ( +
+

+ Dit gewas is niet oogstbaar. +

+
+ ) : loaderData.fieldAmount > 0 ? ( + + ) : ( +
+

+ Selecteer eerst percelen in de + linkerkolom. +

+
+ )} +
+
+
+
+
+ {loaderData.b_lu_harvestable === "once" && + showOverwriteWarning && ( + + + + + Bestaande oogst overschrijven? + + + Er is al een oogst geregistreerd voor + één of meerdere van de geselecteerde + percelen. Als u doorgaat, worden eerdere + oogsten verwijderd en overschreven. + + + + + + + + + )} +
+
+ ) +} + +export async function action({ request, params }: ActionFunctionArgs) { + try { + const { b_id_farm, calendar = "all" } = params + if (!b_id_farm) { + throw new Error("Farm ID is missing") + } + + const session = await getSession(request) + const url = new URL(request.url) + const fieldIds = + url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? [] + const cultivationIds = + url.searchParams + .get("cultivationIds") + ?.split(",") + .filter(Boolean) ?? [] + + if (!fieldIds || fieldIds.length === 0) { + return dataWithError(null, "Selecteer eerst een perceel.") + } + + if (cultivationIds.length !== 1) { + return dataWithError(null, "Selecteer precies één gewas.") + } + + const cultivationCatalogueData = await getCultivationsFromCatalogue( + fdm, + session.principal_id, + b_id_farm, + ) + + const targetCultivation = cultivationCatalogueData.find( + (c: { + b_lu_catalogue: string + b_lu_harvestable: "once" | "multiple" | "none" + }) => c.b_lu_catalogue === cultivationIds[0], + ) + + if (!targetCultivation) { + return dataWithError(null, "Gewas niet gevonden.") + } + + const b_lu_harvestable = targetCultivation.b_lu_harvestable + + if (b_lu_harvestable === "none") { + return dataWithError(null, "Dit gewas is niet oogstbaar.") + } + + const validatedData = await extractFormValuesFromRequest( + request, + FormSchema, + ) + + for (const fieldId of fieldIds) { + const cultivationsForField = await getCultivations( + fdm, + session.principal_id, + fieldId, + { start: new Date(0), end: new Date() }, // Get all cultivations for the field + ) + + const targetCultivationInstance = cultivationsForField.find( + (c) => c.b_lu_catalogue === cultivationIds[0], + ) + + if (!targetCultivationInstance) { + return dataWithError( + null, + `Gewas niet gevonden voor perceel ${fieldId}.`, + ) + } + + const b_lu = targetCultivationInstance.b_lu + + if (b_lu_harvestable === "once") { + // Check for existing harvests for this specific cultivation instance + const existingHarvests = await getHarvests( + fdm, + session.principal_id, + b_lu, + { + start: new Date(0), // Get all harvests + end: new Date(), + }, + ) + + if (existingHarvests.length > 0) { + // If there are existing harvests, remove them before adding new ones + for (const harvest of existingHarvests) { + await removeHarvest( + fdm, + session.principal_id, + harvest.b_id_harvesting, + ) + } + } + } + + await addHarvest( + fdm, + session.principal_id, + b_lu, + validatedData.b_lu_harvest_date, + validatedData.b_lu_yield, + validatedData.b_lu_n_harvestable, + ) + } + + return redirectWithSuccess(`/farm/${b_id_farm}/${calendar}/rotation`, { + message: `Oogst succesvol toegevoegd aan ${fieldIds.length} ${fieldIds.length === 1 ? "perceel" : "percelen"}.`, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return dataWithError( + null, + "Invoer is ongeldig. Controleer het formulier.", + ) + } + throw handleActionError(error) + } +} From c0519a88ee40a68c75283a6201c24ca7d5614980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 13 Nov 2025 15:10:57 +0100 Subject: [PATCH 09/54] Implement fertilizer management route for the cultivation fertilizer application form --- .../blocks/fertilizer-applications/form.tsx | 4 +-- ...m.$calendar.rotation.fertilizer._index.tsx | 25 ++++++------------- .../farm.$b_id_farm.fertilizers.new.tsx | 8 +----- 3 files changed, 10 insertions(+), 27 deletions(-) diff --git a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx index 6019eba49..63860d8c3 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx @@ -153,9 +153,7 @@ export function FertilizerApplicationForm({ ) } navigate( - searchParams.has("fieldIds") - ? `./manage/new?fieldIds=${searchParams.get("fieldIds")}` - : `./manage/new?cultivationIds=${searchParams.get("cultivationIds")}`, + `/farm/${b_id_farm}/fertilizers/new?returnUrl=${encodeURIComponent(`${location.pathname}${location.search}`)}`, ) } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx index 8bbcb8a54..09b8af760 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx @@ -7,7 +7,7 @@ import { getFertilizers, getFields, } from "@svenvw/fdm-core" -import { Info, AlertTriangle, ChevronDown } from "lucide-react" +import { AlertTriangle, Info } from "lucide-react" import { useEffect, useState } from "react" import { type ActionFunctionArgs, @@ -33,6 +33,12 @@ import { FormSchema } from "~/components/blocks/fertilizer-applications/formsche import { Header } from "~/components/blocks/header/base" import { HeaderFarm } from "~/components/blocks/header/farm" import { LoadingSpinner } from "~/components/custom/loadingspinner" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "~/components/ui/accordion" import { Badge } from "~/components/ui/badge" import { BreadcrumbItem, BreadcrumbSeparator } from "~/components/ui/breadcrumb" import { Button } from "~/components/ui/button" @@ -45,12 +51,6 @@ import { CardTitle, } from "~/components/ui/card" import { Checkbox } from "~/components/ui/checkbox" -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "~/components/ui/accordion" import { Dialog, DialogContent, @@ -278,13 +278,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) { ), // All fields for selection cultivationName: cultivationName, cultivationIds: cultivationIds, - fertilizerApplication: { - // Dummy data for now - p_id: "", - p_app_amount: 0, - p_app_method: "", - p_app_date: new Date(), - }, } } catch (error) { throw handleLoaderError(error) @@ -641,9 +634,7 @@ export default function FarmRotationFertilizerAddIndex() { b_id_or_b_lu_catalogue={ selectedFieldIds.join(",") || "" } - fertilizerApplication={ - loaderData.fertilizerApplication - } + fertilizerApplication={undefined} /> ) : (
diff --git a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.tsx b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.tsx index fd0d18090..93eb8775c 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.tsx @@ -104,18 +104,12 @@ export default function FarmFertilizerBlock({ params }: Route.ComponentProps) { const loaderData = useLoaderData() const returnUrl = searchParams.get("returnUrl") - const fieldsMatch = - returnUrl && - /farm\/[^/]+\/[^/]+\/field(?:\/[^/]+)?\/fertilizer(?:\/|$|\?)/.test( - returnUrl, - ) - const createMatch = returnUrl && /farm\/create/.test(returnUrl) return (
Date: Thu, 13 Nov 2025 15:29:43 +0100 Subject: [PATCH 10/54] Add fieldIds search param to cultivation plan add fertilizer application --- ..._id_farm.$calendar.rotation.fertilizer._index.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx index 09b8af760..49c9df7e6 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx @@ -74,6 +74,7 @@ import { clientConfig } from "~/lib/config" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" +import { modifySearchParams } from "../lib/url-utils" export const meta: MetaFunction = () => { return [ @@ -628,7 +629,16 @@ export default function FarmRotationFertilizerAddIndex() { options={ loaderData.fertilizerOptions } - action={`${location.pathname}${location.search}`} + action={modifySearchParams( + `${location.pathname}${location.search}`, + (searchParams) => + searchParams.set( + "fieldIds", + selectedFieldIds.join( + ",", + ), + ), + )} navigation={navigation} b_id_farm={loaderData.b_id_farm} b_id_or_b_lu_catalogue={ From 9c224a3786d5a81b55aea76f8ec53bd61be3ef0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 13 Nov 2025 15:33:29 +0100 Subject: [PATCH 11/54] Also to add harvest --- ..._id_farm.$calendar.rotation.fertilizer._index.tsx | 2 +- ....$b_id_farm.$calendar.rotation.harvest._index.tsx | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx index 49c9df7e6..33fc99759 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx @@ -74,7 +74,7 @@ import { clientConfig } from "~/lib/config" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" -import { modifySearchParams } from "../lib/url-utils" +import { modifySearchParams } from "~/lib/url-utils" export const meta: MetaFunction = () => { return [ diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index 7c7299fea..e158c3175 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -71,6 +71,7 @@ import { clientConfig } from "~/lib/config" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" +import { modifySearchParams } from "~/lib/url-utils" export const meta: MetaFunction = () => { return [ @@ -556,7 +557,16 @@ export default function FarmRotationHarvestAddIndex() { b_lu_harvestable={ loaderData.b_lu_harvestable } - action={`${location.pathname}${location.search}`} + action={modifySearchParams( + `${location.pathname}${location.search}`, + (searchParams) => + searchParams.set( + "fieldIds", + selectedFieldIds.join( + ",", + ), + ), + )} /> ) : (
From f91b378ee802c23ade4cb47e60d4fc941838349d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 13 Nov 2025 16:06:21 +0100 Subject: [PATCH 12/54] Fix wrong path to fields --- fdm-app/app/components/blocks/rotation/columns.tsx | 3 ++- .../routes/farm.$b_id_farm.$calendar.rotation._index.tsx | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/fdm-app/app/components/blocks/rotation/columns.tsx b/fdm-app/app/components/blocks/rotation/columns.tsx index d4ddbc4e2..4db089610 100644 --- a/fdm-app/app/components/blocks/rotation/columns.tsx +++ b/fdm-app/app/components/blocks/rotation/columns.tsx @@ -32,6 +32,7 @@ export type RotationExtended = { b_lu_harvestable: "once" | "multiple" | "nonde" b_lu_start: Date[] b_lu_end: Date[] + calendar: string fields: { b_id: string b_name: string @@ -190,7 +191,7 @@ export const columns: ColumnDef[] = [
{fieldsSorted.map((field) => ( diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx index 82f01a158..e2fc3a00d 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx @@ -1,5 +1,5 @@ import { - CultivationCatalogue, + type CultivationCatalogue, getCultivations, getCultivationsFromCatalogue, getCurrentSoilData, @@ -30,12 +30,11 @@ import { BreadcrumbItem, BreadcrumbSeparator } from "~/components/ui/breadcrumb" import { Button } from "~/components/ui/button" import { SidebarInset } from "~/components/ui/sidebar" import { getSession } from "~/lib/auth.server" -import { getTimeframe } from "~/lib/calendar" +import { getCalendar, getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { useFieldFilterStore } from "~/store/field-filter" -import { getCultivationCatalogue } from "@svenvw/fdm-data" export const meta: MetaFunction = () => { return [ @@ -79,7 +78,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // Get the session const session = await getSession(request) - // Get timeframe from calendar store + // Get calendar and timeframe from calendar store + const calendar = getCalendar(params) const timeframe = getTimeframe(params) // Get a list of possible farms of the user @@ -267,6 +267,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { )?.b_lu_harvestable ?? "once", b_lu_start: b_lu_start, b_lu_end: b_lu_end, + calendar: calendar, fields: fieldsWithThisCultivation.map((field: any) => ({ // TODO: Define a proper type for field b_id: field.b_id, b_name: field.b_name, From 044411e3c8678cbc641c4a419abb5ce8c59bbea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 13 Nov 2025 16:11:51 +0100 Subject: [PATCH 13/54] Key using cultivation ids since it is more semantically correct --- .../farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx index 33fc99759..c7c894a70 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx @@ -642,7 +642,9 @@ export default function FarmRotationFertilizerAddIndex() { navigation={navigation} b_id_farm={loaderData.b_id_farm} b_id_or_b_lu_catalogue={ - selectedFieldIds.join(",") || "" + searchParams.get( + "cultivationIds", + ) || "cultivationIds" } fertilizerApplication={undefined} /> From 64db20de28397c40364bf5f818d6b59935b428e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 13 Nov 2025 20:36:50 +0100 Subject: [PATCH 14/54] Catch unharvestable fields early and display a toast notification --- .../components/blocks/rotation/columns.tsx | 4 +-- .../app/components/blocks/rotation/table.tsx | 30 +++++++++++++++---- ...farm.$calendar.rotation.harvest._index.tsx | 4 --- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/fdm-app/app/components/blocks/rotation/columns.tsx b/fdm-app/app/components/blocks/rotation/columns.tsx index 4db089610..f562af0da 100644 --- a/fdm-app/app/components/blocks/rotation/columns.tsx +++ b/fdm-app/app/components/blocks/rotation/columns.tsx @@ -29,7 +29,7 @@ export type RotationExtended = { b_lu: string[] b_lu_name: string b_lu_croprotation: string - b_lu_harvestable: "once" | "multiple" | "nonde" + b_lu_harvestable: "once" | "multiple" | "none" b_lu_start: Date[] b_lu_end: Date[] calendar: string @@ -204,7 +204,7 @@ export const columns: ColumnDef[] = [ ) - }, [cultivation.fields]) + }, [cultivation.calendar, cultivation.fields]) return fieldsDisplay }, diff --git a/fdm-app/app/components/blocks/rotation/table.tsx b/fdm-app/app/components/blocks/rotation/table.tsx index 37c7db24e..0f3dd51cc 100644 --- a/fdm-app/app/components/blocks/rotation/table.tsx +++ b/fdm-app/app/components/blocks/rotation/table.tsx @@ -45,6 +45,7 @@ import { FieldFilterToggle } from "../../custom/field-filter-toggle" import type { RotationExtended } from "./columns" import { format } from "date-fns" import { nl } from "date-fns/locale/nl" +import { toast as notify } from "sonner" interface DataTableProps { columns: ColumnDef[] @@ -189,10 +190,18 @@ export function DataTable({ ? "Selecteer één of meerdere gewassen om bemesting toe te voegen" : "Bemesting toevoegen aan geselecteerd gewas" - const isHarvestButtonDisabled = selectedCultivationIds.length !== 1 - const harvestTooltipContent = isHarvestButtonDisabled - ? "Selecteer één gewas om oogst toe te voegen" - : "Oogst toevoegen aan geselecteerd gewas" + const isHarvestButtonDisabled = + selectedCultivationIds.length !== 1 + const harvestTooltipContent = + selectedCultivationIds.length !== 1 + ? "Selecteer één gewas om oogst toe te voegen" + : "Oogst toevoegen aan geselecteerd gewas" + const harvestErrorMessage = + selectedCultivations.length > 0 + ? selectedCultivations[0].b_lu_harvestable === "none" + ? "Dit perceel kan niet worden geoogst." + : null + : null return (
@@ -279,8 +288,17 @@ export function DataTable({
{isHarvestButtonDisabled ? ( + ) : harvestErrorMessage ? ( + - - - - - )} + {showOverwriteWarning && ( + { + if (!open) { + resolveConfirmation(false) + } + }} + > + + + + Bestaande oogst overschrijven? + + + Er is al een oogst geregistreerd voor één of + meerdere van de geselecteerde percelen. Als + u doorgaat, worden eerdere oogsten + verwijderd en overschreven. + + + + + + + + + )} ) From 7cde4e1eaf2348690ff4a635d2cee7ce82848de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Tue, 18 Nov 2025 10:09:02 +0100 Subject: [PATCH 22/54] Reset submitted also if the confirmation dialog is rejected --- fdm-app/app/components/blocks/harvest/form.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/fdm-app/app/components/blocks/harvest/form.tsx b/fdm-app/app/components/blocks/harvest/form.tsx index f6585f808..6482b7b18 100644 --- a/fdm-app/app/components/blocks/harvest/form.tsx +++ b/fdm-app/app/components/blocks/harvest/form.tsx @@ -59,6 +59,7 @@ export function HarvestForm({ // If submitting, handle the confirmation procedure // (it might just return true without a dialog) if (submitting.current && !(await handleConfirmation(values))) { + submitting.current = false return { values: {}, errors: true } } // Reset the submitting state before any page redirects can happen From ebd92771ee47514d9c8ebdc7c2f5225096af115f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Tue, 18 Nov 2025 12:09:38 +0100 Subject: [PATCH 23/54] Resolve nitpicks --- .../farm.$b_id_farm.$calendar.rotation.harvest._index.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index 85c9b19e3..cd01b70d6 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -341,7 +341,6 @@ export default function FarmRotationHarvestAddIndex() { } function resolveConfirmation(response: boolean) { - console.error("resolveConfirmation", response) if (resolveConfirmationPromise) { resolveConfirmationPromise[0](response) } @@ -657,8 +656,6 @@ export default function FarmRotationHarvestAddIndex() { Annuleren + +
+
+ +
+
+ ) +} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index 114ef7503..222adb5c2 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -1,6 +1,5 @@ import { addHarvest, - getCultivation, getCultivations, getCultivationsFromCatalogue, getDefaultsForHarvestParameters, @@ -8,11 +7,9 @@ import { getFields, getHarvests, getParametersForHarvestCat, - HarvestableAnalysis, - HarvestParameters, removeHarvest, } from "@svenvw/fdm-core" -import { Info, AlertTriangle, ChevronDown } from "lucide-react" +import { AlertTriangle, Info } from "lucide-react" import { useEffect, useState } from "react" import { type ActionFunctionArgs, @@ -34,7 +31,7 @@ import { import { z } from "zod" import { FarmContent } from "~/components/blocks/farm/farm-content" import { FarmTitle } from "~/components/blocks/farm/farm-title" -import { HarvestFormDialog } from "~/components/blocks/harvest/form" +import { HarvestForm } from "~/components/blocks/harvest/form" import { FormSchema } from "~/components/blocks/harvest/schema" import { Header } from "~/components/blocks/header/base" import { HeaderFarm } from "~/components/blocks/header/farm" @@ -51,12 +48,6 @@ import { CardTitle, } from "~/components/ui/card" import { Checkbox } from "~/components/ui/checkbox" -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "~/components/ui/accordion" import { Dialog, DialogContent, @@ -615,7 +606,7 @@ export default function FarmRotationHarvestAddIndex() {

) : loaderData.fieldAmount > 0 ? ( - Date: Thu, 20 Nov 2025 13:50:10 +0100 Subject: [PATCH 30/54] Change title and description based on if this is a harvest update on rotation --- ...id_farm.$calendar.rotation.harvest._index.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index 222adb5c2..2253685dc 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -334,6 +334,8 @@ export default function FarmRotationHarvestAddIndex() { selectedFieldIds.includes(field.b_id!), ) + const isHarvestUpdate = loaderData.harvestApplication.b_lu_harvest_date + // Confirmation Handling const [resolveConfirmationPromise, setResolveConfirmationPromise] = useState<[(value: boolean) => void]>() @@ -401,13 +403,21 @@ export default function FarmRotationHarvestAddIndex() { - Oogst toevoegen + {isHarvestUpdate ? "Oogst bijwerken" : "Oogst toevoegen"}
{isSubmitting && ( From e817ab91fb5c9251361c9054c50250590a0ef07c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 20 Nov 2025 14:53:07 +0100 Subject: [PATCH 31/54] Search in all selected fields for a harvest application --- ...farm.$calendar.rotation.harvest._index.tsx | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index 2253685dc..b27b2299b 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -208,39 +208,43 @@ export async function loader({ request, params }: LoaderFunctionArgs) { b_lu_end: undefined, b_lu_harvestable: undefined, } - let harvestableAnalysis: HarvestApplication["harvestable"]["harvestable_analyses"]["number"] = - null - let targetFieldCultivation: - | (typeof selectedFields)[number]["cultivations"][number] - | undefined = null + let harvestableAnalysis: Partial< + HarvestApplication["harvestable"]["harvestable_analyses"][number] + > = {} if ( targetCultivation.b_lu_harvestable === "once" && selectedFields.length > 0 ) { - // For cultivations that can only be harvested once, we assume - // one harvesting, one harvestable, one harvestable analysis - targetFieldCultivation = selectedFields[0].cultivations.find( - (c) => c.b_lu_catalogue === targetCultivation.b_lu_catalogue, - ) - if (targetFieldCultivation) { - const harvests = await getHarvests( - fdm, - session.principal_id, - targetFieldCultivation.b_lu, + // Find a field that has the cultivation + // Some selected fields might not have a harvest anymore + // if they were changed before the user changes their selection + for (const field of selectedFields) { + const targetFieldCultivation = field.cultivations.find( + (c) => + c.b_lu_catalogue === targetCultivation.b_lu_catalogue, ) - if (harvests.length > 0) { - harvestApplication = harvests[0] - if ( - harvests[0].harvestable?.harvestable_analyses.length > 0 - ) { - harvestableAnalysis = - harvests[0].harvestable.harvestable_analyses[0] + if (targetFieldCultivation) { + // For cultivations that can only be harvested once, we assume + // one harvesting, one harvestable, one harvestable analysis + const harvests = await getHarvests( + fdm, + session.principal_id, + targetFieldCultivation.b_lu, + ) + if (harvests.length > 0) { + harvestApplication = harvests[0] + break } } } - if (!harvestableAnalysis) { + if ( + harvestApplication?.harvestable?.harvestable_analyses.length > 0 + ) { + harvestableAnalysis = + harvestApplication.harvestable.harvestable_analyses[0] + } else { harvestableAnalysis = getDefaultsForHarvestParameters( targetCultivation.b_lu_catalogue, cultivationCatalogueData, @@ -277,7 +281,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { cultivations: field.cultivations.map((c) => c.b_lu_catalogue), })), fieldOptions: fieldOptions, // All fields for selection - fieldCultivation: targetFieldCultivation, + cultivation: targetCultivation, cultivationName: targetCultivation?.b_lu_name ?? "onbekend gewas", cultivationIds: cultivationIds, b_lu_harvestable: targetCultivation.b_lu_harvestable ?? "once", @@ -661,16 +665,15 @@ export default function FarmRotationHarvestAddIndex() { .b_lu_n_harvestable } b_lu_harvestable={ - loaderData.fieldCultivation + loaderData.cultivation .b_lu_harvestable } b_lu_start={ - loaderData.fieldCultivation + loaderData.cultivation .b_lu_start } b_lu_end={ - loaderData.fieldCultivation - .b_lu_end + loaderData.cultivation.b_lu_end } action={modifySearchParams( `${location.pathname}${location.search}`, From b0cc6421858e4902309dcb05c84557eb01ad3cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 20 Nov 2025 15:54:53 +0100 Subject: [PATCH 32/54] Replace quick action on the front page with cultivation plan --- fdm-app/app/routes/farm.$b_id_farm._index.tsx | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm._index.tsx b/fdm-app/app/routes/farm.$b_id_farm._index.tsx index b2c6d809d..1b5614fc6 100644 --- a/fdm-app/app/routes/farm.$b_id_farm._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm._index.tsx @@ -8,9 +8,9 @@ import { Icon, Landmark, MapIcon, - Plus, ScrollText, Shapes, + Sprout, Square, Trash2, UserRoundCheck, @@ -155,44 +155,45 @@ export default function FarmDashboardIndex() { Snelle acties
- +
- +
- Bemesting toevoegen + Perceelsoverzicht - Voor één of meerdere - percelen. + Uitgebreide tabel + met o.a. gewassen en + meststoffen per + perceel.
- +
- +
- Perceelsoverzicht + Bouwplan Uitgebreide tabel - met o.a. gewassen en - meststoffen per - perceel. + met o.a. zaaidata, + oogstdata en + bemestingen per + gewas.
From a1efd1ffbfd093ba48fa158000160c225fb4f517 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:18:25 +0100 Subject: [PATCH 33/54] fix: safe sorting of empty array's --- fdm-app/app/components/blocks/fields/columns.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fdm-app/app/components/blocks/fields/columns.tsx b/fdm-app/app/components/blocks/fields/columns.tsx index 4ac51ea59..cfc39f4ce 100644 --- a/fdm-app/app/components/blocks/fields/columns.tsx +++ b/fdm-app/app/components/blocks/fields/columns.tsx @@ -128,9 +128,9 @@ export const columns: ColumnDef[] = [ enableSorting: true, sortingFn: (rowA, rowB, _columnId) => { const fertilizerA = - rowA.original.fertilizers[0].p_name_nl || "" + rowA.original.fertilizers[0]?.p_name_nl || "" const fertilizerB = - rowB.original.fertilizers[0].p_name_nl || "" + rowB.original.fertilizers[0]?.p_name_nl || "" return fertilizerA.localeCompare(fertilizerB) }, header: ({ column }) => { From d4d8b5b9553235d29e99852059dfa6f13a1da0a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 21 Nov 2025 11:28:37 +0100 Subject: [PATCH 34/54] Get default harvest parameters for all number of allowed harvests --- ....$b_id_farm.$calendar.rotation.harvest._index.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index b27b2299b..033208bb5 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -244,14 +244,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { ) { harvestableAnalysis = harvestApplication.harvestable.harvestable_analyses[0] - } else { - harvestableAnalysis = getDefaultsForHarvestParameters( - targetCultivation.b_lu_catalogue, - cultivationCatalogueData, - ) } } + if (!harvestableAnalysis) { + harvestableAnalysis = getDefaultsForHarvestParameters( + targetCultivation.b_lu_catalogue, + cultivationCatalogueData, + ) + } + const fieldOptions = allFieldsWithCultivations.map((field) => { if (!field?.b_id || !field?.b_name) { throw new Error("Invalid field data structure") From 3ea2fc20aa5f6b9f4ff0bde58aa950c8909d5dc2 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:30:39 +0100 Subject: [PATCH 35/54] refactor: improve error message for missing harvest parameters --- ...eld.$b_id.cultivation.$b_lu.harvest.$b_id_harvesting.tsx | 6 +++++- ....$calendar.field.$b_id.cultivation.$b_lu.harvest.new.tsx | 6 +++++- .../farm.$b_id_farm.$calendar.rotation.harvest._index.tsx | 6 +++++- ...ations.$b_lu_catalogue.crop.harvest.$b_id_harvesting.tsx | 6 +++++- ...lendar.cultivations.$b_lu_catalogue.crop.harvest.new.tsx | 6 +++++- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.$b_id_harvesting.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.$b_id_harvesting.tsx index 57bd079c6..e130ba167 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.$b_id_harvesting.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.$b_id_harvesting.tsx @@ -21,6 +21,7 @@ import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" import { getCalendar } from "~/lib/calendar" +import { getHarvestParameterLabel } from "../components/blocks/harvest/parameters" // Meta export const meta: MetaFunction = () => { @@ -228,6 +229,9 @@ export async function action({ request, params }: ActionFunctionArgs) { missingParameters.push(param) } } + const missingParameterLabels = missingParameters.map((param) => { + return getHarvestParameterLabel(param) + }) if (missingParameters.length > 0) { return dataWithWarning( @@ -236,7 +240,7 @@ export async function action({ request, params }: ActionFunctionArgs) { ", ", )}`, }, - `Missing required harvest parameters: ${missingParameters.join( + `Voor de volgende parameters ontbreekt een waarde: ${missingParameterLabels.join( ", ", )}`, ) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.new.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.new.tsx index 181f03f14..7df9881d4 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.new.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.new.tsx @@ -20,6 +20,7 @@ import { clientConfig } from "~/lib/config" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" +import { getHarvestParameterLabel } from "../components/blocks/harvest/parameters" // Meta export const meta: MetaFunction = () => { @@ -147,6 +148,9 @@ export async function action({ request, params }: ActionFunctionArgs) { missingParameters.push(param) } } + const missingParameterLabels = missingParameters.map((param) => { + return getHarvestParameterLabel(param) + }) if (missingParameters.length > 0) { return dataWithWarning( @@ -155,7 +159,7 @@ export async function action({ request, params }: ActionFunctionArgs) { ", ", )}`, }, - `Missing required harvest parameters: ${missingParameters.join( + `Voor de volgende parameters ontbreekt een waarde: ${missingParameterLabels.join( ", ", )}`, ) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index b27b2299b..3e79cf76a 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -72,6 +72,7 @@ import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" import { modifySearchParams } from "~/lib/url-utils" +import { getHarvestParameterLabel } from "../components/blocks/harvest/parameters" export const meta: MetaFunction = () => { return [ @@ -879,6 +880,9 @@ export async function action({ request, params }: ActionFunctionArgs) { missingParameters.push(param) } } + const missingParameterLabels = missingParameters.map((param) => { + return getHarvestParameterLabel(param) + }) if (missingParameters.length > 0) { return dataWithWarning( @@ -887,7 +891,7 @@ export async function action({ request, params }: ActionFunctionArgs) { ", ", )}`, }, - `Missing required harvest parameters: ${missingParameters.join( + `Voor de volgende parameters ontbreekt een waarde: ${missingParameterLabels.join( ", ", )}`, ) diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.cultivations.$b_lu_catalogue.crop.harvest.$b_id_harvesting.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.cultivations.$b_lu_catalogue.crop.harvest.$b_id_harvesting.tsx index 007fab8d4..a92c195b4 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.cultivations.$b_lu_catalogue.crop.harvest.$b_id_harvesting.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.cultivations.$b_lu_catalogue.crop.harvest.$b_id_harvesting.tsx @@ -21,6 +21,7 @@ import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" import { HarvestFormDialog } from "../components/blocks/harvest/form" +import { getHarvestParameterLabel } from "../components/blocks/harvest/parameters" // Meta export const meta: MetaFunction = () => { @@ -252,6 +253,9 @@ export async function action({ request, params }: ActionFunctionArgs) { missingParameters.push(param) } } + const missingParameterLabels = missingParameters.map((param) => { + return getHarvestParameterLabel(param) + }) if (missingParameters.length > 0) { return dataWithWarning( @@ -260,7 +264,7 @@ export async function action({ request, params }: ActionFunctionArgs) { ", ", )}`, }, - `Missing required harvest parameters: ${missingParameters.join( + `Voor de volgende parameters ontbreekt een waarde: ${missingParameterLabels.join( ", ", )}`, ) diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.cultivations.$b_lu_catalogue.crop.harvest.new.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.cultivations.$b_lu_catalogue.crop.harvest.new.tsx index 5fb04cf55..04c34ad3f 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.cultivations.$b_lu_catalogue.crop.harvest.new.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.cultivations.$b_lu_catalogue.crop.harvest.new.tsx @@ -21,6 +21,7 @@ import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" import { HarvestFormDialog } from "../components/blocks/harvest/form" +import { getHarvestParameterLabel } from "../components/blocks/harvest/parameters" // Meta export const meta: MetaFunction = () => { @@ -170,6 +171,9 @@ export async function action({ request, params }: ActionFunctionArgs) { missingParameters.push(param) } } + const missingParameterLabels = missingParameters.map((param) => { + return getHarvestParameterLabel(param) + }) if (missingParameters.length > 0) { return dataWithWarning( @@ -178,7 +182,7 @@ export async function action({ request, params }: ActionFunctionArgs) { ", ", )}`, }, - `Missing required harvest parameters: ${missingParameters.join( + `Voor de volgende parameters ontbreekt een waarde: ${missingParameterLabels.join( ", ", )}`, ) From 477c419a2f7b3d6814e1f420884b6b827b9f6291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 21 Nov 2025 11:31:37 +0100 Subject: [PATCH 36/54] fix: Check for empty object --- .../farm.$b_id_farm.$calendar.rotation.harvest._index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index 033208bb5..63d403224 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -247,7 +247,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } } - if (!harvestableAnalysis) { + if (Object.keys(harvestableAnalysis).length === 0) { harvestableAnalysis = getDefaultsForHarvestParameters( targetCultivation.b_lu_catalogue, cultivationCatalogueData, From a1db2e41abc1977e1be55072b98d46798d27507c Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:31:58 +0100 Subject: [PATCH 37/54] refactor: improve Harvest form components name --- fdm-app/app/components/blocks/harvest/form.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/fdm-app/app/components/blocks/harvest/form.tsx b/fdm-app/app/components/blocks/harvest/form.tsx index d0aeca9e8..39a98a34d 100644 --- a/fdm-app/app/components/blocks/harvest/form.tsx +++ b/fdm-app/app/components/blocks/harvest/form.tsx @@ -138,7 +138,7 @@ function useHarvestRemixForm({ return form } -function FormFields({ +function HarvestFields({ b_lu_harvest_date, harvestParameters, form, @@ -447,7 +447,7 @@ function FormFields({ ) } -function FormExplainer() { +function HarvestFormExplainer() { const [hostname, setHostname] = useState("") useEffect(() => { if (typeof window !== "undefined") { @@ -520,8 +520,8 @@ export function HarvestFormDialog(props: HarvestFormDialogProps) { : "Voeg een oogst toe aan dit gewas. Vul de gegevens in, zodat deze gebruikt kunnen worden in de berekeningen."} - - + + - = 8 ? "h-72 overflow-y-auto w-48" : "w-48"}> + = 8 + ? "h-72 overflow-y-auto w-48" + : "w-48" + } + >
{fieldsSorted.map((field) => ( [] = [ { accessorKey: "b_area", enableSorting: true, - sortingFn: "alphanumeric", + sortingFn: (rowA, rowB, _columnId) => { + const areaA = rowA.original.fields.reduce( + (acc, field) => acc + field.b_area, + 0, + ) + const areaB = rowB.original.fields.reduce( + (acc, field) => acc + field.b_area, + 0, + ) + return areaA - areaB + }, header: ({ column }) => { return }, @@ -229,11 +240,7 @@ export const columns: ColumnDef[] = [ return b_area < 0.1 ? "< 0.1 ha" : `${b_area.toFixed(1)} ha` }, [cultivation.fields]) - return ( -

- {formattedArea} -

- ) + return

{formattedArea}

}, }, ] From 96f0896d74d73760981e24a4e7f6a682707eda30 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:56:17 +0100 Subject: [PATCH 40/54] refactot: disable addHarvest button when cultivation is not harvestable --- fdm-app/app/components/blocks/rotation/table.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/fdm-app/app/components/blocks/rotation/table.tsx b/fdm-app/app/components/blocks/rotation/table.tsx index 51e459fef..83cfbdeb9 100644 --- a/fdm-app/app/components/blocks/rotation/table.tsx +++ b/fdm-app/app/components/blocks/rotation/table.tsx @@ -212,17 +212,21 @@ export function DataTable({ ? "Selecteer één of meerdere gewassen om bemesting toe te voegen" : "Bemesting toevoegen aan geselecteerd gewas" - const isHarvestButtonDisabled = selectedCultivationIds.length !== 1 - const harvestTooltipContent = - selectedCultivationIds.length !== 1 - ? "Selecteer één gewas om oogst toe te voegen" - : "Oogst toevoegen aan geselecteerd gewas" + const isHarvestButtonDisabled = + selectedCultivationIds.length !== 1 || + selectedCultivations[0].b_lu_harvestable === "none" const harvestErrorMessage = selectedCultivations.length > 0 ? selectedCultivations[0].b_lu_harvestable === "none" ? "Dit gewas is niet oogstbaar." : null : null + const harvestTooltipContent = + selectedCultivationIds.length !== 1 + ? "Selecteer één gewas om oogst toe te voegen" + : harvestErrorMessage + ? harvestErrorMessage + : "Oogst toevoegen aan geselecteerd gewas" return (
From a25913cf210bc86f0074f79646ae0373d8970360 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:59:48 +0100 Subject: [PATCH 41/54] refactor: improve text on farm dashboard --- fdm-app/app/routes/farm.$b_id_farm._index.tsx | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm._index.tsx b/fdm-app/app/routes/farm.$b_id_farm._index.tsx index 1b5614fc6..77b5858ec 100644 --- a/fdm-app/app/routes/farm.$b_id_farm._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm._index.tsx @@ -25,29 +25,29 @@ import { import { getSession } from "~/lib/auth.server" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" -import { FarmContent } from "../components/blocks/farm/farm-content" -import { FarmTitle } from "../components/blocks/farm/farm-title" -import { Header } from "../components/blocks/header/base" -import { HeaderFarm } from "../components/blocks/header/farm" -import { Button } from "../components/ui/button" +import { FarmContent } from "~/components/blocks/farm/farm-content" +import { FarmTitle } from "~/components/blocks/farm/farm-title" +import { Header } from "~/components/blocks/header/base" +import { HeaderFarm } from "~/components/blocks/header/farm" +import { Button } from "~/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, -} from "../components/ui/card" +} from "~/components/ui/card" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "../components/ui/select" -import { SidebarInset } from "../components/ui/sidebar" -import { getCalendarSelection } from "../lib/calendar" -import { fdm } from "../lib/fdm.server" -import { useCalendarStore } from "../store/calendar" +} from "~/components/ui/select" +import { SidebarInset } from "~/components/ui/sidebar" +import { getCalendarSelection } from "~/lib/calendar" +import { fdm } from "~/lib/fdm.server" +import { useCalendarStore } from "~/store/calendar" // Meta export const meta: MetaFunction = () => { @@ -142,7 +142,7 @@ export default function FarmDashboardIndex() { @@ -152,7 +152,7 @@ export default function FarmDashboardIndex() { {/* Quick Actions */}

- Snelle acties + Overzichten

@@ -164,11 +164,12 @@ export default function FarmDashboardIndex() {
- Perceelsoverzicht + Percelen Uitgebreide tabel met o.a. gewassen en + gebruikte meststoffen per perceel. @@ -192,7 +193,8 @@ export default function FarmDashboardIndex() { Uitgebreide tabel met o.a. zaaidata, oogstdata en - bemestingen per + gebruikte + meststoffen per gewas.
From 20875bb4f5936743ba430125b7e14bac7db43286 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:02:51 +0100 Subject: [PATCH 42/54] fix: send 400 when cultivation is not known in catalogue --- ...rm.$b_id_farm.$calendar.rotation.harvest._index.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index 622da9385..fe0cb131d 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -183,6 +183,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { (c) => c.b_lu_catalogue === cultivationIds[0], ) + if (!targetCultivation) { + throw new Response( + `Cultivation with ID ${cultivationIds[0]} not found.`, + { + status: 404, + statusText: `Cultivation with ID ${cultivationIds[0]} not found.`, + }, + ) + } + let selectedFields = [] if (fieldIdsFromSearchParams.length > 0) { // If fieldIds are in search params, use them to determine selected fields From cdb16f184a499f89fd3aa2b468ae3022818ae851 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:11:50 +0100 Subject: [PATCH 43/54] fix: import --- fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx index 0c0609a74..ff9bbf882 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx @@ -5,7 +5,6 @@ import { getFertilizerApplications, getFertilizers, getFields, - getHarvests, } from "@svenvw/fdm-core" import { data, From 877aad7530190777f668300c2df809c955c4cb80 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:12:47 +0100 Subject: [PATCH 44/54] refactor: improve text --- ...farm.$calendar.rotation.harvest._index.tsx | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index fe0cb131d..5dfa340e5 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -361,11 +361,7 @@ export default function FarmRotationHarvestAddIndex() { function handleConfirmation() { // Check if this is a new harvest or is has already values - if ( - loaderData.harvestApplication.b_lu_yield !== undefined || - loaderData.harvestApplication.b_lu_n_harvestable !== undefined || - loaderData.harvestApplication.b_lu_harvest_date !== undefined - ) { + if (loaderData.harvestApplication.b_lu_harvest_date !== undefined) { return initiateConfirmation() } @@ -525,7 +521,7 @@ export default function FarmRotationHarvestAddIndex() { - Naar bouwplanoverzicht + Terug naar bouwplan
@@ -616,13 +612,21 @@ export default function FarmRotationHarvestAddIndex() { - Oogst toevoegen + + {isHarvestUpdate + ? "Oogst bijwerken" + : "Oogst toevoegen"} + {loaderData.fieldAmount === 0 ? "Selecteer eerst een of meerdere percelen." : loaderData.fieldAmount === 1 - ? "Voeg een nieuwe oogst toe aan het geselecteerde perceel." - : `Voeg een nieuwe oogst toe aan de ${loaderData.fieldAmount} geselecteerde percelen.`} + ? isHarvestUpdate + ? "Werk oogst bij van het geselecteerde perceel." + : "Voeg een nieuwe oogst toe aan de geselecteerde perceel." + : isHarvestUpdate + ? "Werk oogst bij van de geselecteerde percelen." + : `Voeg een nieuwe oogst toe aan de ${loaderData.fieldAmount} geselecteerde percelen.`} @@ -732,8 +736,8 @@ export default function FarmRotationHarvestAddIndex() { Er is al een oogst geregistreerd voor één of meerdere van de geselecteerde percelen. Als - u doorgaat, worden eerdere oogsten - verwijderd en overschreven. + u doorgaat, worden de opgeslagen oogsten + overschreven. From a7d459d3fbef5a8ba9010e3cec1d5a5db2c235c1 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:24:06 +0100 Subject: [PATCH 45/54] fix: confirmation check if first field is not harvested yet --- ...farm.$calendar.rotation.harvest._index.tsx | 109 ++++++++++++------ 1 file changed, 73 insertions(+), 36 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index 5dfa340e5..aad3036e7 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -193,15 +193,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) { ) } - let selectedFields = [] + let selectedFieldsData = [] if (fieldIdsFromSearchParams.length > 0) { // If fieldIds are in search params, use them to determine selected fields - selectedFields = allFieldsWithCultivations.filter((field) => + selectedFieldsData = allFieldsWithCultivations.filter((field) => fieldIdsFromSearchParams.includes(field.b_id), ) } else { // Otherwise, default to fields with the selected cultivation - selectedFields = allFieldsWithCultivations.filter((field) => + selectedFieldsData = allFieldsWithCultivations.filter((field) => field.cultivations.some((c) => cultivationIds.includes(c.b_lu_catalogue), ), @@ -211,33 +211,22 @@ export async function loader({ request, params }: LoaderFunctionArgs) { type HarvestApplication = Awaited< ReturnType >[number] - let harvestApplication: HarvestApplication = { - b_lu_yield: undefined, - b_lu_n_harvestable: undefined, - b_lu_harvest_date: undefined, - b_lu_start: undefined, - b_lu_end: undefined, - b_lu_harvestable: undefined, - } - let harvestableAnalysis: Partial< - HarvestApplication["harvestable"]["harvestable_analyses"][number] - > = {} - if ( - targetCultivation.b_lu_harvestable === "once" && - selectedFields.length > 0 - ) { - // Find a field that has the cultivation - // Some selected fields might not have a harvest anymore - // if they were changed before the user changes their selection - for (const field of selectedFields) { + const selectedFields = await Promise.all( + selectedFieldsData.map(async (field) => { + let harvestApplication: HarvestApplication | undefined = + undefined + let harvestableAnalysis: Partial< + HarvestApplication["harvestable"]["harvestable_analyses"][number] + > = {} + let hasHarvest = false + const targetFieldCultivation = field.cultivations.find( (c) => c.b_lu_catalogue === targetCultivation.b_lu_catalogue, ) + if (targetFieldCultivation) { - // For cultivations that can only be harvested once, we assume - // one harvesting, one harvestable, one harvestable analysis const harvests = await getHarvests( fdm, session.principal_id, @@ -245,20 +234,58 @@ export async function loader({ request, params }: LoaderFunctionArgs) { ) if (harvests.length > 0) { harvestApplication = harvests[0] - break + hasHarvest = + harvestApplication.b_lu_yield !== undefined || + harvestApplication.b_lu_n_harvestable !== + undefined || + harvestApplication.b_lu_harvest_date !== undefined + if ( + harvestApplication?.harvestable + ?.harvestable_analyses.length > 0 + ) { + harvestableAnalysis = + harvestApplication.harvestable + .harvestable_analyses[0] + } } } - } - if ( - harvestApplication?.harvestable?.harvestable_analyses.length > 0 - ) { - harvestableAnalysis = - harvestApplication.harvestable.harvestable_analyses[0] - } + return { + ...field, + hasHarvest, + harvestApplication, + harvestableAnalysis, + } + }), + ) + + let firstFieldWithData + if (targetCultivation.b_lu_harvestable === "once") { + firstFieldWithData = selectedFields.find((f) => f.hasHarvest) } - if (Object.keys(harvestableAnalysis).length === 0) { + let harvestApplication: + | HarvestApplication + | Partial = + firstFieldWithData?.harvestApplication ?? { + b_lu_yield: undefined, + b_lu_n_harvestable: undefined, + b_lu_harvest_date: undefined, + b_lu_start: undefined, + b_lu_end: undefined, + b_lu_harvestable: undefined, + } + + let harvestableAnalysis: Partial< + HarvestApplication["harvestable"]["harvestable_analyses"][number] + > = firstFieldWithData?.harvestableAnalysis ?? {} + + // Apply defaults if no harvest data was found to pre-fill the form + // This applies to both 'once' (if no existing harvest) and 'multiple' harvestable crops + if ( + selectedFields.length > 0 && // Ensure fields are selected + Object.keys(harvestableAnalysis).length === 0 // Check if harvestableAnalysis is still empty + ) { harvestableAnalysis = getDefaultsForHarvestParameters( targetCultivation.b_lu_catalogue, cultivationCatalogueData, @@ -291,7 +318,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { b_id: field.b_id, b_name: field.b_name, b_area: Math.round(field.b_area * 10) / 10, - cultivations: field.cultivations.map((c) => c.b_lu_catalogue), + cultivations: field.cultivations.map( + (c: { b_lu_catalogue: string }) => c.b_lu_catalogue, + ), + hasHarvest: field.hasHarvest, })), fieldOptions: fieldOptions, // All fields for selection cultivation: targetCultivation, @@ -360,8 +390,15 @@ export default function FarmRotationHarvestAddIndex() { useState>() function handleConfirmation() { - // Check if this is a new harvest or is has already values - if (loaderData.harvestApplication.b_lu_harvest_date !== undefined) { + if (loaderData.b_lu_harvestable === "multiple") { + return Promise.resolve(true) + } + // Check if any of the currently selected fields already have a harvest. + const hasExistingHarvest = loaderData.selectedFields + .filter((field) => selectedFieldIds.includes(field.b_id!)) + .some((field) => field.hasHarvest) + + if (hasExistingHarvest) { return initiateConfirmation() } From d3c673c53804878e1cb433ca946634a2f5c6b31e Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:26:49 +0100 Subject: [PATCH 46/54] fix: Make harvest writes atomic across fields --- ...farm.$calendar.rotation.harvest._index.tsx | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index aad3036e7..1c6c10f0a 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -847,6 +847,8 @@ export async function action({ request, params }: ActionFunctionArgs) { } if (request.method === "DELETE") { + const removalPromises: Promise[] = [] + for (const fieldId of fieldIds) { const cultivationsForField = await getCultivations( fdm, @@ -874,16 +876,20 @@ export async function action({ request, params }: ActionFunctionArgs) { session.principal_id, b_lu, ) - // If there are existing harvests, remove them before adding new ones + // If there are existing harvests, add their removal to the promises for (const harvest of existingHarvests) { - await removeHarvest( - fdm, - session.principal_id, - harvest.b_id_harvesting, + removalPromises.push( + removeHarvest( + fdm, + session.principal_id, + harvest.b_id_harvesting, + ), ) } } + await Promise.all(removalPromises) + return redirectWithSuccess( `/farm/${b_id_farm}/${calendar}/rotation`, { @@ -897,6 +903,8 @@ export async function action({ request, params }: ActionFunctionArgs) { FormSchema, ) + const promises: Promise[] = [] + for (const fieldId of fieldIds) { const cultivationsForField = await getCultivations( fdm, @@ -971,24 +979,30 @@ export async function action({ request, params }: ActionFunctionArgs) { if (existingHarvests.length > 0) { // If there are existing harvests, remove them before adding new ones for (const harvest of existingHarvests) { - await removeHarvest( - fdm, - session.principal_id, - harvest.b_id_harvesting, + promises.push( + removeHarvest( + fdm, + session.principal_id, + harvest.b_id_harvesting, + ), ) } } } - await addHarvest( - fdm, - session.principal_id, - b_lu, - formValues.b_lu_harvest_date, - harvestProperties, + promises.push( + addHarvest( + fdm, + session.principal_id, + b_lu, + formValues.b_lu_harvest_date, + harvestProperties, + ), ) } + await Promise.all(promises) + return redirectWithSuccess(`/farm/${b_id_farm}/${calendar}/rotation`, { message: `Oogst succesvol toegevoegd aan ${fieldIds.length} ${fieldIds.length === 1 ? "perceel" : "percelen"}.`, }) From 5504f4836b782d9e29314170e21bf19dfff394cf Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:30:46 +0100 Subject: [PATCH 47/54] Revert "fix: Make harvest writes atomic across fields" This reverts commit d3c673c53804878e1cb433ca946634a2f5c6b31e. --- ...farm.$calendar.rotation.harvest._index.tsx | 44 +++++++------------ 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index 1c6c10f0a..aad3036e7 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -847,8 +847,6 @@ export async function action({ request, params }: ActionFunctionArgs) { } if (request.method === "DELETE") { - const removalPromises: Promise[] = [] - for (const fieldId of fieldIds) { const cultivationsForField = await getCultivations( fdm, @@ -876,20 +874,16 @@ export async function action({ request, params }: ActionFunctionArgs) { session.principal_id, b_lu, ) - // If there are existing harvests, add their removal to the promises + // If there are existing harvests, remove them before adding new ones for (const harvest of existingHarvests) { - removalPromises.push( - removeHarvest( - fdm, - session.principal_id, - harvest.b_id_harvesting, - ), + await removeHarvest( + fdm, + session.principal_id, + harvest.b_id_harvesting, ) } } - await Promise.all(removalPromises) - return redirectWithSuccess( `/farm/${b_id_farm}/${calendar}/rotation`, { @@ -903,8 +897,6 @@ export async function action({ request, params }: ActionFunctionArgs) { FormSchema, ) - const promises: Promise[] = [] - for (const fieldId of fieldIds) { const cultivationsForField = await getCultivations( fdm, @@ -979,30 +971,24 @@ export async function action({ request, params }: ActionFunctionArgs) { if (existingHarvests.length > 0) { // If there are existing harvests, remove them before adding new ones for (const harvest of existingHarvests) { - promises.push( - removeHarvest( - fdm, - session.principal_id, - harvest.b_id_harvesting, - ), + await removeHarvest( + fdm, + session.principal_id, + harvest.b_id_harvesting, ) } } } - promises.push( - addHarvest( - fdm, - session.principal_id, - b_lu, - formValues.b_lu_harvest_date, - harvestProperties, - ), + await addHarvest( + fdm, + session.principal_id, + b_lu, + formValues.b_lu_harvest_date, + harvestProperties, ) } - await Promise.all(promises) - return redirectWithSuccess(`/farm/${b_id_farm}/${calendar}/rotation`, { message: `Oogst succesvol toegevoegd aan ${fieldIds.length} ${fieldIds.length === 1 ? "perceel" : "percelen"}.`, }) From 37f9ec154092c7451cd2133b3b3c8fe8287474f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 21 Nov 2025 13:10:39 +0100 Subject: [PATCH 48/54] fix: Handle the case of no selected fields in loader --- ...id_farm.$calendar.rotation.fertilizer._index.tsx | 13 ++++++++----- ...$b_id_farm.$calendar.rotation.harvest._index.tsx | 13 ++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx index c7c894a70..c8861c47d 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx @@ -151,7 +151,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // Get fieldIds from search params (if any) const fieldIdsFromSearchParams = - url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? [] + url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? null // Filter fields based on cultivationIds or fieldIdsFromSearchParams let selectedFields = [] @@ -174,11 +174,14 @@ export async function loader({ request, params }: LoaderFunctionArgs) { cultivationName = targetCultivation.b_lu_name } - if (fieldIdsFromSearchParams.length > 0) { + if (fieldIdsFromSearchParams) { // If fieldIds are in search params, use them to determine selected fields - selectedFields = allFieldsWithCultivations.filter((field) => - fieldIdsFromSearchParams.includes(field.b_id!), - ) + selectedFields = + fieldIdsFromSearchParams.length > 0 + ? allFieldsWithCultivations.filter((field) => + fieldIdsFromSearchParams.includes(field.b_id!), + ) + : [] } else { // Otherwise, default to fields with the selected cultivation selectedFields = allFieldsWithCultivations.filter((field) => diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index aad3036e7..a6ed952ad 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -171,7 +171,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // Get fieldIds from search params (if any) const fieldIdsFromSearchParams = - url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? [] + url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? null const cultivationCatalogueData = await getCultivationsFromCatalogue( fdm, @@ -194,11 +194,14 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } let selectedFieldsData = [] - if (fieldIdsFromSearchParams.length > 0) { + if (fieldIdsFromSearchParams) { // If fieldIds are in search params, use them to determine selected fields - selectedFieldsData = allFieldsWithCultivations.filter((field) => - fieldIdsFromSearchParams.includes(field.b_id), - ) + selectedFieldsData = + fieldIdsFromSearchParams.length > 0 + ? allFieldsWithCultivations.filter((field) => + fieldIdsFromSearchParams.includes(field.b_id), + ) + : [] } else { // Otherwise, default to fields with the selected cultivation selectedFieldsData = allFieldsWithCultivations.filter((field) => From 09c5abde45b7222158fe22d0c0b26c89bc13cb25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 21 Nov 2025 13:26:50 +0100 Subject: [PATCH 49/54] refactor: Improve typechecking in the add harvest to cultivation route --- .../farm.$b_id_farm.$calendar.rotation.harvest._index.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx index a6ed952ad..c50e282e2 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -217,8 +217,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const selectedFields = await Promise.all( selectedFieldsData.map(async (field) => { - let harvestApplication: HarvestApplication | undefined = - undefined + let harvestApplication: HarvestApplication | undefined let harvestableAnalysis: Partial< HarvestApplication["harvestable"]["harvestable_analyses"][number] > = {} @@ -262,12 +261,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }), ) - let firstFieldWithData + let firstFieldWithData: (typeof selectedFields)[number] | undefined if (targetCultivation.b_lu_harvestable === "once") { firstFieldWithData = selectedFields.find((f) => f.hasHarvest) } - let harvestApplication: + const harvestApplication: | HarvestApplication | Partial = firstFieldWithData?.harvestApplication ?? { From bfff011290967a75d7820fde42ddae6b39d926a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 21 Nov 2025 13:31:08 +0100 Subject: [PATCH 50/54] refactor: Parallelize add fertilizer api calls in the add fertilizer to cultivation route --- ...m.$calendar.rotation.fertilizer._index.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx index c8861c47d..0351ed40f 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx @@ -690,17 +690,19 @@ export async function action({ request, params }: ActionFunctionArgs) { FormSchema, ) - for (const fieldId of fieldIds) { - await addFertilizerApplication( - fdm, - session.principal_id, - fieldId, - validatedData.p_id, - validatedData.p_app_amount, - validatedData.p_app_method, - validatedData.p_app_date, - ) - } + await Promise.all( + fieldIds.map((fieldId) => + addFertilizerApplication( + fdm, + session.principal_id, + fieldId, + validatedData.p_id, + validatedData.p_app_amount, + validatedData.p_app_method, + validatedData.p_app_date, + ), + ), + ) return redirectWithSuccess(`/farm/${b_id_farm}/${calendar}/rotation`, { message: `Bemesting succesvol toegevoegd aan ${fieldIds.length} ${fieldIds.length === 1 ? "perceel" : "percelen"}.`, From 4e4a0ceaf7225357444cbef51f2d01b023652eb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 21 Nov 2025 14:03:25 +0100 Subject: [PATCH 51/54] fix: Handle date input prop value of type Date --- fdm-app/app/components/custom/date-picker-v2.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fdm-app/app/components/custom/date-picker-v2.tsx b/fdm-app/app/components/custom/date-picker-v2.tsx index 4adabdc14..6fa05683d 100644 --- a/fdm-app/app/components/custom/date-picker-v2.tsx +++ b/fdm-app/app/components/custom/date-picker-v2.tsx @@ -48,7 +48,9 @@ export function DatePicker({ const [month, setMonth] = useState(selectedDate) useEffect(() => { - if (field.value) { + if (field.value && field.value instanceof Date) { + field.onChange(field.value.toISOString()) + } else if (field.value) { const date = parseDateText(field.value) setSelectedDate(date || undefined) setInputValue(date ? formatDate(date) : "") @@ -58,7 +60,7 @@ export function DatePicker({ setSelectedDate(undefined) setMonth(undefined) } - }, [field.value]) + }, [field.value, field.onChange]) const handleInputChange = (e: ChangeEvent) => { setInputValue(e.target.value) From 813e01b0238d90570c6edfab23e8c649cf6ca50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 21 Nov 2025 14:16:53 +0100 Subject: [PATCH 52/54] refactor: Unify how changes in the open state of the selection dialog are handled --- ...m.$calendar.rotation.fertilizer._index.tsx | 20 +++++++++++++++--- ...farm.$calendar.rotation.harvest._index.tsx | 21 +++++++++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx index 0351ed40f..9bd695011 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx @@ -331,6 +331,13 @@ export default function FarmRotationFertilizerAddIndex() { selectedFieldIds.includes(field.b_id!), ) + function handleSelectionDialogOpenChange(open: boolean) { + if (!open) { + handleSelectionChange() + } + setOpen(open) + } + return (
- +