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/.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/fertilizer-applications/form.tsx b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx index 36d5c8722..63860d8c3 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, @@ -153,7 +158,7 @@ export function FertilizerApplicationForm({ } return ( - +
[] = [ 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/components/blocks/harvest/form.tsx b/fdm-app/app/components/blocks/harvest/form.tsx index f2c34e12f..a73bd2663 100644 --- a/fdm-app/app/components/blocks/harvest/form.tsx +++ b/fdm-app/app/components/blocks/harvest/form.tsx @@ -1,4 +1,8 @@ import { zodResolver } from "@hookform/resolvers/zod" +import type { HarvestParameters } from "@svenvw/fdm-core" +import { CircleQuestionMark } from "lucide-react" +import { useEffect, useState } from "react" +import { Controller } from "react-hook-form" import { Form, useFetcher, useNavigate } from "react-router" import { RemixFormProvider, useRemixForm } from "remix-hook-form" import type { z } from "zod" @@ -6,8 +10,11 @@ import { cn } from "@/app/lib/utils" import { DatePicker } from "~/components/custom/date-picker-v2" import { LoadingSpinner } from "~/components/custom/loadingspinner" import { Button } from "~/components/ui/button" -import { Input } from "~/components/ui/input" -import { FormSchema } from "./schema" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "~/components/ui/collapsible" import { Dialog, DialogClose, @@ -24,16 +31,9 @@ import { FieldLabel, FieldSet, } from "~/components/ui/field" -import { Controller } from "react-hook-form" -import type { HarvestParameters } from "@svenvw/fdm-core" -import { CircleQuestionMark } from "lucide-react" -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "~/components/ui/collapsible" -import { useState, useEffect } from "react" +import { Input } from "~/components/ui/input" import { getHarvestParameterLabel } from "./parameters" +import { FormSchema } from "./schema" type HarvestFormDialogProps = { harvestParameters: HarvestParameters @@ -50,9 +50,11 @@ type HarvestFormDialogProps = { b_lu_harvestable: "once" | "multiple" | "none" b_lu_start: Date | undefined | null b_lu_end: Date | undefined | null + action: string + handleConfirmation?: (data: z.infer) => Promise } -export function HarvestFormDialog({ +function useHarvestRemixForm({ harvestParameters, b_lu_harvest_date, b_lu_yield, @@ -67,20 +69,35 @@ export function HarvestFormDialog({ b_lu_harvestable, b_lu_start, b_lu_end, + handleConfirmation, }: HarvestFormDialogProps) { - const navigate = useNavigate() - const fetcher = useFetcher() - const [hostname, setHostname] = useState("") - - useEffect(() => { - if (typeof window !== "undefined") { - setHostname(window.location.hostname) - } - }, []) - const form = useRemixForm>({ mode: "onTouched", - resolver: zodResolver(FormSchema), + resolver: async (values, bypass, options) => { + // Do the validation using Zod + const validation = await zodResolver(FormSchema)( + values, + bypass, + options, + ) + // If there are validation errors anyways, just return them + if ( + validation.errors && + Object.keys(validation.errors).length > 0 + ) { + return validation + } + // If submitting, handle the confirmation procedure + // (it might just return true without a dialog) + if ( + form.formState.isSubmitting && + handleConfirmation && + !(await handleConfirmation(values)) + ) { + return { values: {}, errors: true } + } + return validation + }, defaultValues: { b_lu_harvest_date: b_lu_harvest_date ? new Date(b_lu_harvest_date) @@ -118,8 +135,363 @@ export function HarvestFormDialog({ }, }) + return form +} + +function HarvestFields({ + b_lu_harvest_date, + harvestParameters, + form, + className, +}: HarvestFormDialogProps & { + form: ReturnType + className: React.ComponentProps["className"] +}) { + return ( + + ( + + )} + /> + ( + + + {getHarvestParameterLabel(field.name)} + + + {fieldState.invalid && ( + + )} + + )} + /> + + ( + + + {getHarvestParameterLabel(field.name)} + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + {getHarvestParameterLabel(field.name)} + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + {" "} + {getHarvestParameterLabel(field.name)} + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + {getHarvestParameterLabel(field.name)} + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + {getHarvestParameterLabel(field.name)} + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + {getHarvestParameterLabel(field.name)} + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + {getHarvestParameterLabel(field.name)} + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + {getHarvestParameterLabel(field.name)} + + + {fieldState.invalid && ( + + )} + + )} + /> + + ) +} + +function HarvestFormExplainer() { + const [hostname, setHostname] = useState("") + useEffect(() => { + if (typeof window !== "undefined") { + setHostname(window.location.hostname) + } + }, []) + + return ( + + + + +

Waarom zie ik deze oogstparameters?

+
+ +

+ De getoonde oogstparameters zijn gebaseerd op de meest + gangbare praktijkgegevens voor dit gewas. Deze waarden + zijn nodig voor een nauwkeurige + stikstofbalansberekening. Komen deze niet overeen met uw + eigen metingen? Stuur dan een e-mail naar{" "} + + support@ + {hostname} + {" "} + met welke parameters volgens u gemeten worden voor dit + gewas. Alvast bedankt! +

+
+
+
+ ) +} +export function HarvestFormDialog(props: HarvestFormDialogProps) { + const { b_lu_harvest_date, action } = props + const navigate = useNavigate() + const fetcher = useFetcher() + const form = useHarvestRemixForm(props) + const handleDeleteHarvest = () => { - return fetcher.submit(null, { method: "DELETE" }) + return fetcher.submit(null, { method: "DELETE", action: action }) } // Check if this is a new harvest or is has already values @@ -132,6 +504,7 @@ export function HarvestFormDialog({ id="formHarvest" onSubmit={form.handleSubmit} method="post" + action={action} >
@@ -147,409 +520,8 @@ export function HarvestFormDialog({ : "Voeg een oogst toe aan dit gewas. Vul de gegevens in, zodat deze gebruikt kunnen worden in de berekeningen."} - - - ( - - )} - /> - ( - - - {getHarvestParameterLabel( - field.name, - )} - - - {fieldState.invalid && ( - - )} - - )} - /> - - ( - - - {getHarvestParameterLabel( - field.name, - )} - - - {fieldState.invalid && ( - - )} - - )} - /> - ( - - - {getHarvestParameterLabel( - field.name, - )} - - - {fieldState.invalid && ( - - )} - - )} - /> - ( - - - {" "} - {getHarvestParameterLabel( - field.name, - )} - - - {fieldState.invalid && ( - - )} - - )} - /> - ( - - - {getHarvestParameterLabel( - field.name, - )} - - - {fieldState.invalid && ( - - )} - - )} - /> - ( - - - {getHarvestParameterLabel( - field.name, - )} - - - {fieldState.invalid && ( - - )} - - )} - /> - ( - - - {getHarvestParameterLabel( - field.name, - )} - - - {fieldState.invalid && ( - - )} - - )} - /> - ( - - - {getHarvestParameterLabel( - field.name, - )} - - - {fieldState.invalid && ( - - )} - - )} - /> - ( - - - {getHarvestParameterLabel( - field.name, - )} - - - {fieldState.invalid && ( - - )} - - )} - /> - - - - - -

- Waarom zie ik deze oogstparameters? -

-
- -

- De getoonde oogstparameters zijn - gebaseerd op de meest gangbare - praktijkgegevens voor dit gewas. - Deze waarden zijn nodig voor een - nauwkeurige - stikstofbalansberekening. Komen deze - niet overeen met uw eigen metingen? - Stuur dan een e-mail naar{" "} - - support@ - {hostname} - {" "} - met welke parameters volgens u - gemeten worden voor dit gewas. - Alvast bedankt! -

-
-
-
+ + + + +
+ + + + ) +} 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..4d3492ba4 --- /dev/null +++ b/fdm-app/app/components/blocks/rotation/columns.tsx @@ -0,0 +1,246 @@ +import React from "react" +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 { HarvestDatesDisplay } from "./harvest-dates-display" +import { FertilizerDisplay } from "./fertilizer-display" +import { Row } from "@react-email/components" + +export type RotationExtended = { + b_lu_catalogue: string + b_lu: string[] + b_lu_name: string + b_lu_croprotation: string + b_lu_harvestable: "once" | "multiple" | "none" + b_lu_start: Date[] + b_lu_end: Date[] + calendar: string + fields: { + b_id: string + b_name: string + b_area: number + b_isproductive: boolean + a_som_loi: number + b_soiltype_agr: string + b_lu_harvest_date: Date[] + 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: "datetime", + header: ({ column }) => { + return + }, + enableHiding: true, // Enable hiding for mobile + cell: ({ row }) => { + const cultivation = row.original + + 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 }) + } + 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 })}` + }, [cultivation.b_lu_start]) + + return

{formattedDateRange}

+ }, + }, + { + accessorKey: "b_harvest_date", + enableSorting: false, + header: ({ column }) => { + return + }, + enableHiding: true, // Enable hiding for mobile + cell: ({ row }) => { + const cultivation = row.original + return + }, + }, + { + accessorKey: "fertilizers", + enableSorting: false, + enableHiding: true, // Enable hiding for mobile + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const cultivation = row.original + return + }, + }, + { + accessorKey: "b_name", + enableSorting: true, + sortingFn: (rowA, rowB, _columnId) => { + const fieldA = rowA.original.fields.length + const fieldB = rowB.original.fields.length + return fieldA - fieldB + }, + enableHiding: true, // Enable hiding for mobile + header: ({ column }) => { + return + }, + cell: ({ row }) => { + const cultivation = row.original + + 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.calendar, cultivation.fields]) + + return fieldsDisplay + }, + }, + { + accessorKey: "b_area", + enableSorting: true, + 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 + }, + enableHiding: true, // Enable hiding for mobile + cell: ({ row }) => { + const cultivation = row.original + + 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

{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/components/blocks/rotation/table.tsx b/fdm-app/app/components/blocks/rotation/table.tsx new file mode 100644 index 000000000..83cfbdeb9 --- /dev/null +++ b/fdm-app/app/components/blocks/rotation/table.tsx @@ -0,0 +1,427 @@ +import { + type ColumnDef, + type ColumnFiltersState, + type FilterFn, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + getFacetedRowModel, + 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" +import { toast as notify } from "sonner" +import { useFieldFilterStore } from "@/app/store/field-filter" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) + const [searchTerms, setSearchTerms] = 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: 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]) + + const fuzzySearchAndProductivityFilter: FilterFn = ( + row, + _columnId, + { searchTerms, showProductiveOnly }, + ) => { + if ( + showProductiveOnly && + !row.original.fields.some((field) => field.b_isproductive) + ) { + return false + } + return ( + searchTerms === "" || + fuzzysort.go(searchTerms, [(row.original as any).searchTarget]) + .length > 0 + ) + } + + const showProductiveOnly = useFieldFilterStore((s) => s.showProductiveOnly) + const globalFilter = useMemo( + () => ({ searchTerms, showProductiveOnly }), + [searchTerms, showProductiveOnly], + ) + const table = useReactTable({ + data: memoizedData, + columns, + getCoreRowModel: getCoreRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onGlobalFilterChange: (globalFilter) => { + if (globalFilter?.searchTerms ?? "" !== searchTerms) + setSearchTerms(globalFilter?.searchTerms ?? "") + }, + onRowSelectionChange: setRowSelection, + globalFilterFn: fuzzySearchAndProductivityFilter, + 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 || + 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 ( +
+
+ setSearchTerms(event.target.value)} + className="w-full sm:w-auto sm:grow" + /> +
+ + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + const columnNames: Record = + { + b_lu_name: "Gewas", + b_lu_start: "Zaaidatum", + b_harvest_date: "Oogstdata", + fertilizers: "Bemesting", + b_name: "Percelen", + b_area: "Oppervlakte", + } + return ( + + column.toggleVisibility(!!value) + } + > + {columnNames[column.id] ?? + column.id} + + ) + })} + + + + + + +
+ {isFertilizerButtonDisabled ? ( + + ) : ( + + + + )} +
+
+ +

{fertilizerTooltipContent}

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

{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 d5ae03245..e85d04510 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" @@ -67,6 +68,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` @@ -193,14 +203,26 @@ export function SidebarFarm() { )} - {/* - - - - Gewassen - - - */} + + {rotationLink ? ( + + + + Bouwplan + + + ) : ( + + + + Bouwplan + + + )} + {fertilizersLink ? ( diff --git a/fdm-app/app/components/custom/date-picker-v2.tsx b/fdm-app/app/components/custom/date-picker-v2.tsx index 4adabdc14..2a6132140 100644 --- a/fdm-app/app/components/custom/date-picker-v2.tsx +++ b/fdm-app/app/components/custom/date-picker-v2.tsx @@ -47,8 +47,11 @@ export function DatePicker({ ) const [month, setMonth] = useState(selectedDate) + // biome-ignore lint/correctness/useExhaustiveDependencies: onChange is stable across renders for react-hook-form controllers 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) : "") 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/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.field._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx index 17a455441..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 @@ -3,6 +3,7 @@ import { getCurrentSoilData, getFarms, getFertilizerApplications, + getFertilizers, getFields, } from "@svenvw/fdm-core" import { @@ -111,6 +112,12 @@ 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 +134,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 +158,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, 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..92d4594f9 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx @@ -0,0 +1,400 @@ +import { + type CultivationCatalogue, + getCultivations, + getCultivationsFromCatalogue, + getCurrentSoilData, + getFarms, + getFertilizerApplications, + getFertilizers, + getFields, + getHarvests, +} from "@svenvw/fdm-core" +import { + 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, + 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" +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 { getCalendar, getTimeframe } from "~/lib/calendar" +import { clientConfig } from "~/lib/config" +import { handleLoaderError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" + +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 new Response("Not Found", { + status: 404, + statusText: "Not Found", + }) + } + + // Get the session + const session = await getSession(request) + + // Get calendar and timeframe from calendar store + const calendar = getCalendar(params) + 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 cultivationCatalogue = await getCultivationsFromCatalogue( + fdm, + session.principal_id, + b_id_farm, + ) + + function getHarvestabilityFromCatalogue(b_lu_catalogue: string) { + return cultivationCatalogue.find( + (item: { b_lu_catalogue: string }) => item.b_lu_catalogue === b_lu_catalogue, + )?.b_lu_harvestable ?? "once" + } + + 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.flatMap(async (cultivation) => { + const b_lu_harvestable = getHarvestabilityFromCatalogue(cultivation.b_lu_catalogue) + + const harvests = await getHarvests( + fdm, + session.principal_id, + cultivation.b_lu, + b_lu_harvestable === "once" ? undefined : timeframe, + ) + + return { + b_lu: cultivation.b_lu, + b_lu_harvest_date: harvests.map( + (harvest) => harvest.b_lu_harvest_date, + ), + } + }), + ) + + const fertilizerApplications = await getFertilizerApplications( + fdm, + session.principal_id, + field.b_id, + timeframe, + ) + + 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, + session.principal_id, + field.b_id, + timeframe, + ) + const a_som_loi = + currentSoilData.find( + (item: { parameter: string }) => item.parameter === "a_som_loi", + )?.value ?? null + const b_soiltype_agr = + currentSoilData.find( + (item: { parameter: string }) => item.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, + } + }), + ) + + 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: { b_lu_catalogue: string }) => + cultivation.b_lu_catalogue === b_lu_catalogue, + ), + ) + + const fieldsWithThisCultivation = fieldsExtended.filter( + (field) => + field.cultivations.some( + (cultivation: { b_lu_catalogue: string }) => + cultivation.b_lu_catalogue === b_lu_catalogue, + ), + ) + + // Get all unique b_lu_start of cultivation + const b_lu_start = [ + ...new Set( + cultivationsForCatalogue.map((cultivation: { b_lu_start: Date }) => + cultivation.b_lu_start.getTime(), + ), + ), + ].map((timestamp) => new Date(timestamp)) + + const b_lu_end = [ + ...new Set( + cultivationsForCatalogue + .filter((cultivation: { b_lu_end: Date | null }) => cultivation.b_lu_end) + .map((cultivation: { b_lu_end: Date }) => cultivation.b_lu_end.getTime()), + ), + ].map((timestamp) => new Date(timestamp)) + + const b_lu = cultivationsForCatalogue.map( + (cultivation: { b_lu: string }) => cultivation.b_lu, + ) + + return { + b_lu_catalogue: b_lu_catalogue, + b_lu: b_lu, + b_lu_name: cultivationsForCatalogue[0]?.b_lu_name ?? "", + b_lu_croprotation: + cultivationsForCatalogue[0]?.b_lu_croprotation ?? "", + b_lu_harvestable: getHarvestabilityFromCatalogue(b_lu_catalogue), + 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, + 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 ?? "", + b_lu_harvest_date: field.harvests + .filter((harvest: { b_lu: string }) => b_lu.includes(harvest.b_lu)) + .flatMap( + (harvest: { b_lu_harvest_date: Date[] }) => harvest.b_lu_harvest_date, + ), + fertilizerApplications: + 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: { 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, + ) + + // Return user information from loader + return { + b_id_farm: b_id_farm, + farmOptions: farmOptions, + fieldOptions: fieldOptions, + rotationExtended: rotationExtended, // Return filtered data + 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 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 + :( +

+
+
+ + + +
+
+ + ) : ( + <> +
+ +
+ +
+ +
+
+ + )} +
+
+ ) +} 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 new file mode 100644 index 000000000..9bd695011 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.fertilizer._index.tsx @@ -0,0 +1,733 @@ +import { + addFertilizerApplication, + getCultivations, + getCultivationsFromCatalogue, + getFarms, + getFertilizerParametersDescription, + getFertilizers, + getFields, +} from "@svenvw/fdm-core" +import { AlertTriangle, Info } 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 { + FertilizerApplicationForm, + type FertilizerOption, +} from "~/components/blocks/fertilizer-applications/form" +import { FormSchema } from "~/components/blocks/fertilizer-applications/formschema" +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" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { Checkbox } from "~/components/ui/checkbox" +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" +import { modifySearchParams } from "~/lib/url-utils" + +export const meta: MetaFunction = () => { + 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) ?? null + + // 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) { + // If fieldIds are in search params, use them to determine selected fields + 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) => + 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, + } + } 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!), + ) + + function handleSelectionDialogOpenChange(open: boolean) { + if (!open) { + handleSelectionChange() + } + setOpen(open) + } + + 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 ? ( + + searchParams.set( + "fieldIds", + selectedFieldIds.join( + ",", + ), + ), + )} + navigation={navigation} + b_id_farm={loaderData.b_id_farm} + b_id_or_b_lu_catalogue={ + searchParams.get( + "cultivationIds", + ) || "cultivationIds" + } + fertilizerApplication={undefined} + /> + ) : ( +
+

+ 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, + ) + + 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"}.`, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return dataWithError( + null, + "Invoer is ongeldig. Controleer het formulier.", + ) + } + throw handleActionError(error) + } +} 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..eaeb159f0 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.harvest._index.tsx @@ -0,0 +1,1019 @@ +import { + addHarvest, + getCultivations, + getCultivationsFromCatalogue, + getDefaultsForHarvestParameters, + getFarms, + getFields, + getHarvests, + getParametersForHarvestCat, + removeHarvest, +} from "@svenvw/fdm-core" +import { AlertTriangle, Info } 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, + dataWithWarning, + 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 { + 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" +import { modifySearchParams } from "~/lib/url-utils" +import { getHarvestParameterLabel } from "../components/blocks/harvest/parameters" + +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) ?? [] + + if (cultivationIds.length === 0) { + throw data("missing: cultivationIds", { + status: 400, + statusText: "missing: cultivationIds", + }) + } + + // 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: { + b_id: string + b_name: string + b_area: number + }) => { + const cultivations = await getCultivations( + fdm, + session.principal_id, + field.b_id, + timeframe, + ) + return { + ...field, + cultivations: cultivations, + } + }, + ), + ) + + // Get fieldIds from search params (if any) + const fieldIdsFromSearchParams = + url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? null + + const cultivationCatalogueData = await getCultivationsFromCatalogue( + fdm, + session.principal_id, + b_id_farm, + ) + + const targetCultivation = cultivationCatalogueData.find( + (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 selectedFieldsData = [] + if (fieldIdsFromSearchParams) { + // If fieldIds are in search params, use them to determine selected fields + 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) => + field.cultivations.some((c) => + cultivationIds.includes(c.b_lu_catalogue), + ), + ) + } + + type HarvestApplication = Awaited< + ReturnType + >[number] + + const selectedFields = await Promise.all( + selectedFieldsData.map(async (field) => { + let harvestApplication: HarvestApplication | 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) { + const harvests = await getHarvests( + fdm, + session.principal_id, + targetFieldCultivation.b_lu, + ) + if (harvests.length > 0) { + harvestApplication = harvests[0] + 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] + } + } + } + + return { + ...field, + hasHarvest, + harvestApplication, + harvestableAnalysis, + } + }), + ) + + let firstFieldWithData: (typeof selectedFields)[number] | undefined + if (targetCultivation.b_lu_harvestable === "once") { + firstFieldWithData = selectedFields.find((f) => f.hasHarvest) + } + + const 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, + ) + } + + 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.map((c) => c.b_lu_catalogue), // Pass cultivations for each field + } + }) + + const harvestParameters = getParametersForHarvestCat( + targetCultivation.b_lu_harvestcat, + ) + + // 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: field.b_id, + b_name: field.b_name, + b_area: Math.round(field.b_area * 10) / 10, + cultivations: field.cultivations.map( + (c: { b_lu_catalogue: string }) => c.b_lu_catalogue, + ), + hasHarvest: field.hasHarvest, + })), + fieldOptions: fieldOptions, // All fields for selection + cultivation: targetCultivation, + cultivationName: targetCultivation?.b_lu_name ?? "onbekend gewas", + cultivationIds: cultivationIds, + b_lu_harvestable: targetCultivation.b_lu_harvestable ?? "once", + harvestApplication: harvestApplication, + harvestableAnalysis: harvestableAnalysis, + harvestParameters: harvestParameters, + } + } 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 }) + } + + 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!), + ) + + const isHarvestUpdate = loaderData.harvestApplication.b_lu_harvest_date + + function handleSelectionDialogOpenChange(open: boolean) { + if (!open) { + handleSelectionChange() + } + setOpen(open) + } + + // Confirmation Handling + const [resolveConfirmationPromise, setResolveConfirmationPromise] = + useState<[(value: boolean) => void]>() + const [confirmationPromise, setConfirmationPromise] = + useState>() + + function handleConfirmation() { + 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() + } + + return Promise.resolve(true) + } + + function initiateConfirmation() { + if (!showOverwriteWarning) { + setShowOverwriteWarning(true) + } + + if (confirmationPromise) { + return confirmationPromise + } + + const myConfirmationPromise = new Promise((resolve) => + setResolveConfirmationPromise([resolve]), + ) + setConfirmationPromise(myConfirmationPromise) + + return myConfirmationPromise + } + + function resolveConfirmation(response: boolean) { + if (resolveConfirmationPromise) { + resolveConfirmationPromise[0](response) + } + + if (confirmationPromise) { + setConfirmationPromise(undefined) + } + + setShowOverwriteWarning(false) + } + + return ( + +
+ + + + Bouwplan + + + + {isHarvestUpdate ? "Oogst bijwerken" : "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 + +
+ ))} +
+
+ + + +
+
+
+
+ + + + {isHarvestUpdate + ? "Oogst bijwerken" + : "Oogst toevoegen"} + + + {loaderData.fieldAmount === 0 + ? "Selecteer eerst een of meerdere percelen." + : loaderData.fieldAmount === 1 + ? 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.`} + + + + {loaderData.b_lu_harvestable === "none" ? ( +
+

+ Dit gewas is niet oogstbaar. +

+
+ ) : loaderData.fieldAmount > 0 ? ( + + searchParams.set( + "fieldIds", + selectedFieldIds.join( + ",", + ), + ), + )} + handleConfirmation={ + handleConfirmation + } + /> + ) : ( +
+

+ Selecteer eerst percelen in de + linkerkolom. +

+
+ )} +
+
+
+
+
+ {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 de opgeslagen oogsten + 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.") + } + + if (request.method === "DELETE") { + 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 + + // Check for existing harvests for this specific cultivation instance + const existingHarvests = await getHarvests( + fdm, + session.principal_id, + b_lu, + ) + // 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, + ) + } + } + + return redirectWithSuccess( + `/farm/${b_id_farm}/${calendar}/rotation`, + { + message: `Oogst succesvol verwijderd van ${fieldIds.length} ${fieldIds.length === 1 ? "perceel" : "percelen"}.`, + }, + ) + } + + const formValues = 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 + + // Get required harvest parameters for the cultivation's harvest category + const requiredHarvestParameters = getParametersForHarvestCat( + targetCultivationInstance.b_lu_harvestcat, + ) + + // Check if all required parameters are present + const missingParameters: string[] = [] + for (const param of requiredHarvestParameters) { + if ( + (formValues as Record)[param] === undefined || + (formValues as Record)[param] === null + ) { + missingParameters.push(param) + } + } + const missingParameterLabels = missingParameters.map((param) => { + return getHarvestParameterLabel(param) + }) + + if (missingParameters.length > 0) { + return dataWithWarning( + { + warning: `Missing required harvest parameters: ${missingParameters.join( + ", ", + )}`, + }, + `Voor de volgende parameters ontbreekt een waarde: ${missingParameterLabels.join( + ", ", + )}`, + ) + } + + // Filter form values to include only required parameters for updateHarvest + const harvestProperties: Record = {} + for (const param of requiredHarvestParameters) { + if ((formValues as Record)[param] !== undefined) { + harvestProperties[param] = ( + formValues as Record + )[param] + } + } + + if (b_lu_harvestable === "once") { + // Check for existing harvests for this specific cultivation instance + const existingHarvests = await getHarvests( + fdm, + session.principal_id, + b_lu, + ) + + 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, + formValues.b_lu_harvest_date, + harvestProperties, + ) + } + + 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) + } +} 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..77b5858ec 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, @@ -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,47 +152,50 @@ export default function FarmDashboardIndex() { {/* Quick Actions */}

- Snelle acties + Overzichten

- +
- +
- Bemesting toevoegen + Percelen - Voor één of meerdere - percelen. + Uitgebreide tabel + met o.a. gewassen en + gebruikte + meststoffen per + perceel.
- +
- +
- Perceelsoverzicht + Bouwplan Uitgebreide tabel - met o.a. gewassen en + met o.a. zaaidata, + oogstdata en + gebruikte meststoffen per - perceel. + gewas.
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 (
{ @@ -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( ", ", )}`, ) diff --git a/fdm-app/package.json b/fdm-app/package.json index 0bbe8b2bf..cd88c5f4d 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 839917d82..31935e9a8 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)