From 08152696f8c3c5b9fdaaa3fefafa7d69b28e0015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Feb 2026 12:03:56 +0100 Subject: [PATCH 01/35] Add modify fertilizer applications dialog --- .../blocks/fertilizer-applications/form.tsx | 199 ++++--- .../blocks/rotation/fertilizer-display.tsx | 56 +- ...endar.rotation.modify_fertilizer.$p_id.tsx | 393 ++++++++++++++ ....$calendar.rotation_.fertilizer._index.tsx | 489 ++++++++++++------ 4 files changed, 879 insertions(+), 258 deletions(-) create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx diff --git a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx index 6988c1182..176022c5a 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx @@ -1,24 +1,20 @@ import { zodResolver } from "@hookform/resolvers/zod" -import type { FertilizerApplication } from "@svenvw/fdm-core" +import { formatDate } from "date-fns" +import { nl } from "date-fns/locale" import { Plus } from "lucide-react" import type { MouseEvent } from "react" import { useEffect, useId } from "react" +import { Controller } from "react-hook-form" import type { Navigation } from "react-router" import { Form, useNavigate, useSearchParams } from "react-router" import { RemixFormProvider, useRemixForm } from "remix-hook-form" import { useFieldFertilizerFormStore } from "@/app/store/field-fertilizer-form" import { Combobox } from "~/components/custom/combobox" -import { DatePicker } from "~/components/custom/date-picker" +import { DatePicker } from "~/components/custom/date-picker-v2" import { Button } from "~/components/ui/button" -import { - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "~/components/ui/form" +import { Field, FieldError, FieldLabel } from "~/components/ui/field" import { Input } from "~/components/ui/input" +import { Label } from "~/components/ui/label" import { Select, SelectContent, @@ -51,13 +47,15 @@ export function FertilizerApplicationForm({ b_id_farm, b_id_or_b_lu_catalogue, fertilizerApplication, + exampleFertilizerApplication, }: { options: FertilizerOption[] action: string navigation: Navigation b_id_farm: string b_id_or_b_lu_catalogue: string - fertilizerApplication: FertilizerApplication + fertilizerApplication?: Partial + exampleFertilizerApplication?: Partial }) { const navigate = useNavigate() const [searchParams] = useSearchParams() @@ -120,7 +118,7 @@ export function FertilizerApplicationForm({ ]) useEffect(() => { - if (fertilizerApplication) { + if (fertilizerApplication?.p_app_amount) { form.setValue("p_app_amount", fertilizerApplication.p_app_amount) } }, [fertilizerApplication, form.setValue]) @@ -158,7 +156,7 @@ export function FertilizerApplicationForm({ } return ( - +
-
-
+
+
} + defaultValue={fertilizerApplication?.p_id} />
-
-

 

+
+
- ( - - Toedieningsmethode + render={({ field, fieldState }) => ( + + Toedieningsmethode - - - + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + Hoeveelheid + { + const val = e.target.value + if (val === "") { + field.onChange(undefined) + } else { + field.onChange( + Number.parseFloat(val), + ) + } + }} + /> + {fieldState.invalid && ( + + )} + )} /> -
- ( - - Hoeveelheid - - { - const val = e.target.value - if (val === "") { - field.onChange( - undefined, - ) - } else { - field.onChange( - Number.parseFloat( - val, - ), - ) - } - }} - type="number" - placeholder="12500 kg/ha" - required - /> - - - - - )} - /> -
-
- -
+ + + + + + ) : ( + includeModifyCellWhenReadonly && + )} + + ) +} + +export default function FertilizerApplicationListDialog() { + const { fertilizer, fertilizerApplications, returnUrl } = + useLoaderData() + + const navigate = useNavigate() + + const canModifyAnything = useMemo( + () => fertilizerApplications.some((app) => app.canModify), + [fertilizerApplications], + ) + + const record = groupAndOrderFertApps(fertilizerApplications) + + return ( + navigate("..")}> + + + {fertilizer.p_name_nl} + + Bekijk en beheer de bemestingen met deze meststof. + + + + + Datum + Toedieningsmethode + Hoeveelheid + {canModifyAnything && } + + + {record.map((record) => ( + + ))} + +
+ + + + + +
+
+ ) +} + +const FormSchema = z.discriminatedUnion("intent", [ + z.object({ + intent: z.literal("remove_application"), + appIds: z + .string() + .transform((str) => str.split(",")) + .refine((ids) => ids.length > 0, { error: "missing: p_app_id" }), + }), +]) + +export async function action({ request }: Route.ActionArgs) { + try { + const session = await getSession(request) + + const formData = await extractFormValuesFromRequest(request, FormSchema) + + if (formData.intent === "remove_application") { + fdm.transaction((tx) => + Promise.all( + formData.appIds.map((p_app_id) => + removeFertilizerApplication( + tx, + session.principal_id, + p_app_id, + ), + ), + ), + ) + return dataWithSuccess( + null, + formData.appIds.length === 1 + ? "Bemesting is verwijderd!" + : "Bemestingen zijn verwijderd!", + ) + } + + throw Error(`invalid intent: ${formData.intent}`) + } catch (e) { + throw handleActionError(e) + } +} 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 22d89f745..d087ed4ee 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 @@ -1,18 +1,22 @@ import { addFertilizerApplication, + type FertilizerApplication, + type Field, getCultivations, getCultivationsFromCatalogue, getFarms, + getFertilizerApplication, getFertilizerParametersDescription, getFertilizers, + getField, getFields, + updateFertilizerApplication, } 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, @@ -74,7 +78,8 @@ 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 { isOfOrigin, modifySearchParams } from "~/lib/url-utils" +import type { Route } from "./+types/farm.$b_id_farm.$calendar.rotation_.fertilizer._index" export const meta: MetaFunction = () => { return [ @@ -86,30 +91,215 @@ export const meta: MetaFunction = () => { ] } -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", - }) +function parseAppIds( + appIdPairs: string[], +): { b_id: string; p_app_id: string }[] { + const applicationRefs: { p_app_id: string; b_id: string }[] = [] + + // Parse application references + for (const appId of appIdPairs as string[]) { + const splitting = appId.split(":") + if (splitting.length < 2) { + throw new Error(`invalid b_id:p_app_id : ${appId}`) } + const [b_id, p_app_id] = splitting + applicationRefs.push({ b_id, p_app_id }) + } - // Get cultivationIds from search params - const url = new URL(request.url) - const cultivationIds = - url.searchParams - .get("cultivationIds") - ?.split(",") - .filter(Boolean) ?? [] + return applicationRefs +} + +type PathParams = Route.LoaderArgs["params"] +interface StrategizedLoaderData { + fields: (Field & { cultivations?: string[] })[] + selectedFieldIds: string[] + canReselect: boolean + appIds: string[] | null + exampleFertilizerApplication?: Partial | null + fertilizerApplication?: Partial | null +} +function loadByStrategy( + principal_id: string, + params: PathParams, + searchParams: URLSearchParams, +) { + // Get cultivationIds from search params + const appIds = searchParams.get("appIds")?.split(",").filter(Boolean) + + if (appIds && appIds.length > 0) { + return loadByAppIds(principal_id, params, appIds) + } + + // Get cultivationIds from search params + const cultivationIds = + searchParams.get("cultivationIds")?.split(",").filter(Boolean) ?? [] + if (!cultivationIds || cultivationIds.length === 0) { + throw data("missing: cultivationIds", { + status: 400, + statusText: "missing: cultivationIds", + }) + } + + // Get fieldIds from search params (if any) + const fieldIdsFromSearchParams = + searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? null + + return loadByCultivationAndFieldIds( + principal_id, + params, + cultivationIds, + fieldIdsFromSearchParams, + ) +} + +async function loadByCultivationAndFieldIds( + principal_id: string, + params: PathParams, + cultivationIds: string[], + fieldIds: string[] | null, +): Promise { + console.log("loading by cultivation and field ids") + const timeframe = getTimeframe(params) + const fields = await getFields( + fdm, + principal_id, + params.b_id_farm, + timeframe, + ) + + const fieldsExtended = await Promise.all( + fields.map(async (field) => { + const cultivations = await getCultivations( + fdm, + principal_id, + field.b_id, + timeframe, + ) + return { + ...field, + cultivations: cultivations.map((c) => c.b_lu_catalogue), + } + }), + ) + + const fieldsWithCultivation = fieldsExtended.filter((field) => + field.cultivations.some((b_lu_catalogue) => + cultivationIds.includes(b_lu_catalogue), + ), + ) + + const fieldIdsSet = new Set(fieldIds) + + const selectedFieldIds = fieldIds + ? fieldsWithCultivation + .map((cultivation) => cultivation.b_id) + .filter((b_id) => fieldIdsSet.has(b_id)) + : fieldsWithCultivation.map((cultivation) => cultivation.b_id) + + return { + fields: fieldsWithCultivation, + selectedFieldIds: selectedFieldIds, + canReselect: true, + appIds: null, + exampleFertilizerApplication: {}, + fertilizerApplication: undefined, + } +} + +async function loadByAppIds( + principal_id: string, + _params: PathParams, + appIdPairs: string[], +): Promise { + console.log("loading by app ids") + + const applicationRefs = parseAppIds(appIdPairs) + + const fields = await Promise.all( + applicationRefs.map((ref) => getField(fdm, principal_id, ref.b_id)), + ) + + const fertilizerApplications = (await Promise.all( + applicationRefs.map(async ({ p_app_id }) => + getFertilizerApplication(fdm, principal_id, p_app_id), + ), + )) as FertilizerApplication[] + + let exampleFertilizerApplication: Partial = {} + let fertilizerApplication: Partial = {} + + if (fertilizerApplications.length > 0) { + // These will be shown as form placeholders at worst + exampleFertilizerApplication = { ...fertilizerApplications[0] } + + // These keys are shown on the form + const keyTypes = { + p_id: "string", + p_app_date: "date", + p_app_method: "string", + p_app_amount: "number", + } as const + const keys = Object.keys(keyTypes) as (keyof typeof keyTypes)[] + + // Select the values that can be shown on the form + fertilizerApplication = Object.fromEntries( + keys.map((key) => [key, exampleFertilizerApplication[key]]), + ) + + // Only keep values that are common between the fertilizer applications + for (const key of keys) { + for (const app of fertilizerApplications) { + if (!exampleFertilizerApplication[key] || !app[key]) { + delete fertilizerApplication[key] + } + if ( + keyTypes[key] === "date" + ? ( + exampleFertilizerApplication[key] as Date + ).getTime() !== (app[key] as Date).getTime() + : exampleFertilizerApplication[key] !== app[key] + ) { + delete fertilizerApplication[key] + } + } + } + + // If the fertilizer types are different, assume the application methods are different too + if (!fertilizerApplication.p_id) { + delete fertilizerApplication.p_app_method + // Also, no specific placeholder should be shown + delete exampleFertilizerApplication.p_app_amount + } + } + + console.log({ + fields: fields as StrategizedLoaderData["fields"], + selectedFieldIds: fields.map((field) => field.b_id), + canReselect: false, + appIds: fertilizerApplications.map((app) => app.p_app_id), + exampleFertilizerApplication: exampleFertilizerApplication, + fertilizerApplication: fertilizerApplication, + }) + + return { + fields: fields as StrategizedLoaderData["fields"], + selectedFieldIds: fields.map((field) => field.b_id), + canReselect: false, + appIds: fertilizerApplications.map((app) => app.p_app_id), + exampleFertilizerApplication: exampleFertilizerApplication, + fertilizerApplication: fertilizerApplication, + } +} + +export async function loader({ request, params }: Route.LoaderArgs) { + try { // Get the session const session = await getSession(request) + const url = new URL(request.url) + // Get timeframe from calendar store - const timeframe = getTimeframe(params) const calendar = getCalendar(params) // Get a list of possible farms of the user @@ -131,79 +321,24 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } }) - // 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], - ) + const { + fields, + selectedFieldIds, + canReselect, + appIds, + exampleFertilizerApplication, + fertilizerApplication, + } = await loadByStrategy(session.principal_id, params, url.searchParams) - 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) => { + 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, - cultivations: field.cultivations, // Pass cultivations for each field + b_area: Math.round((field.b_area ?? 0) * 10) / 10, + cultivations: field.cultivations ?? [], // Pass cultivations for each field } }) @@ -211,7 +346,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const fertilizers = await getFertilizers( fdm, session.principal_id, - b_id_farm, + params.b_id_farm, ) const fertilizerParameterDescription = getFertilizerParametersDescription() @@ -219,69 +354,75 @@ export async function loader({ request, params }: LoaderFunctionArgs) { (x: { parameter: string }) => x.parameter === "p_app_method_options", ) - if (!applicationMethods) throw new Error("Parameter metadata missing") + const applicationMethodOptionsRaw = applicationMethods?.options + if (!applicationMethodOptionsRaw) + 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, + ?.map((opt: string) => { + const meta = applicationMethodOptionsRaw.find( + (x) => x.value === opt, ) return meta ? { value: opt, label: meta.label } : undefined }) .filter( - (option: { - value: string - label: string - }): option is { value: string; label: string } => + (option): option is { value: string; label: string } => option !== undefined, ) return { value: fertilizer.p_id, - label: fertilizer.p_name_nl, + label: fertilizer.p_name_nl ?? "Onbekend", applicationMethodOptions: applicationMethodOptions, } }, ) + const catalogueCultivations = await getCultivationsFromCatalogue( + fdm, + session.principal_id, + params.b_id_farm, + ) + const cultivationIds = + url.searchParams + .get("cultivationIds") + ?.split(",") + .filter(Boolean) ?? [] + const cultivationNames = cultivationIds + .map( + (b_lu_catalogue) => + catalogueCultivations.find( + (c) => c.b_lu_catalogue === b_lu_catalogue, + )?.b_lu_name, + ) + .filter((v) => v) + .sort() + // Return user information from loader return { - b_id_farm: b_id_farm, + b_id_farm: params.b_id_farm, farmOptions: farmOptions, - fieldAmount: selectedFields.length, + fieldAmount: selectedFieldIds.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, + selectedFieldIds: selectedFieldIds, + fieldOptions: fieldOptions.map((field) => ({ + b_id: field.b_id, + b_name: field.b_name, + b_area: field.b_area, + cultivations: field.cultivations, + })), // All fields for selection + cultivationNames: cultivationNames, cultivationIds: cultivationIds, + canReselect: canReselect, + exampleFertilizerApplication: exampleFertilizerApplication, + fertilizerApplication: { + ...fertilizerApplication, + p_app_ids: appIds, + }, create: url.searchParams.has("create"), } } catch (error) { @@ -296,14 +437,12 @@ export default function FarmRotationFertilizerAddIndex() { const [searchParams, setSearchParams] = useSearchParams() const [open, setOpen] = useState(false) const [selectedFieldIds, setSelectedFieldIds] = useState( - loaderData.selectedFields.map((field) => field.b_id!), + loaderData.selectedFieldIds, ) useEffect(() => { - setSelectedFieldIds( - loaderData.selectedFields.map((field) => field.b_id!), - ) - }, [loaderData.selectedFields]) + setSelectedFieldIds(loaderData.selectedFieldIds) + }, [loaderData.selectedFieldIds]) const isSubmitting = navigation.state === "submitting" @@ -329,7 +468,7 @@ export default function FarmRotationFertilizerAddIndex() { } const displayedSelectedFields = loaderData.fieldOptions.filter((field) => - selectedFieldIds.includes(field.b_id!), + selectedFieldIds.includes(field.b_id), ) function handleSelectionDialogOpenChange(open: boolean) { @@ -365,7 +504,7 @@ export default function FarmRotationFertilizerAddIndex() {
@@ -547,9 +686,13 @@ export default function FarmRotationFertilizerAddIndex() { Percelen zonder{" "} - { - loaderData.cultivationName - } + {loaderData + .cultivationNames + .length === + 1 + ? loaderData + .cultivationNames[0] + : "deze gewassen"} @@ -666,7 +809,12 @@ export default function FarmRotationFertilizerAddIndex() { "cultivationIds", ) || "cultivationIds" } - fertilizerApplication={undefined} + fertilizerApplication={ + loaderData.fertilizerApplication + } + exampleFertilizerApplication={ + loaderData.exampleFertilizerApplication + } /> ) : (
@@ -689,12 +837,50 @@ export default function FarmRotationFertilizerAddIndex() { 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 validatedData = await extractFormValuesFromRequest( + request, + FormSchema, + ) + + const appIdPairs = url.searchParams + .get("appIds") + ?.split(",") + .filter(Boolean) + if (appIdPairs && appIdPairs.length > 0) { + const applicationRefs = parseAppIds(appIdPairs) + fdm.transaction((tx) => + Promise.all( + applicationRefs.map((ref) => + updateFertilizerApplication( + tx, + session.principal_id, + ref.p_app_id, + validatedData.p_id, + validatedData.p_app_amount, + validatedData.p_app_method, + validatedData.p_app_date, + ), + ), + ), + ) + + const returnUrl = url.searchParams.get("returnUrl") + return redirectWithSuccess( + returnUrl && isOfOrigin(returnUrl, url.origin) + ? returnUrl + : url.searchParams.has("create") + ? `/farm/create/${b_id_farm}/${calendar}/rotation` + : `/farm/${b_id_farm}/${calendar}/rotation`, + { + message: `Bemesting succesvol toegevoegd aan ${fieldIds.length} ${fieldIds.length === 1 ? "perceel" : "percelen"}.`, + }, + ) + } + const fieldIds = url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? [] @@ -702,21 +888,18 @@ export async function action({ request, params }: ActionFunctionArgs) { 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, + await fdm.transaction((tx) => + Promise.all( + fieldIds.map((fieldId) => + addFertilizerApplication( + tx, + session.principal_id, + fieldId, + validatedData.p_id, + validatedData.p_app_amount, + validatedData.p_app_method, + validatedData.p_app_date, + ), ), ), ) From d67a7d663ade4022c4e4818f94f7a5b1783eebe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Feb 2026 12:59:42 +0100 Subject: [PATCH 02/35] Fix grouping and a few other things --- .../blocks/fertilizer-applications/form.tsx | 10 +- ...endar.rotation.modify_fertilizer.$p_id.tsx | 118 ++++++++---------- ....$calendar.rotation_.fertilizer._index.tsx | 76 +++++------ 3 files changed, 93 insertions(+), 111 deletions(-) diff --git a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx index 176022c5a..c98915c0f 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx @@ -54,8 +54,14 @@ export function FertilizerApplicationForm({ navigation: Navigation b_id_farm: string b_id_or_b_lu_catalogue: string - fertilizerApplication?: Partial - exampleFertilizerApplication?: Partial + fertilizerApplication?: + | Partial + | null + | undefined + exampleFertilizerApplication?: + | Partial + | null + | undefined }) { const navigate = useNavigate() const [searchParams] = useSearchParams() diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx index 2c2588703..f600d06f6 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx @@ -25,6 +25,7 @@ import { Table, TableBody, TableCell, + TableHead, TableHeader, TableRow, } from "~/components/ui/table" @@ -59,27 +60,23 @@ export async function loader({ params, request }: Route.LoaderArgs) { p_name_nl: originalFertilizer.p_name_nl, } - const allApplications = ( - await Promise.all( - fieldIds.map((b_id) => - getFertilizerApplications( - fdm, - session.principal_id, - b_id, - ).then((apps) => - apps.map((app) => ({ ...app, b_id: b_id })), - ), + const allApplicationsPerField = await Promise.all( + fieldIds.map((b_id) => + getFertilizerApplications(fdm, session.principal_id, b_id).then( + (apps) => apps.map((app) => ({ ...app, b_id: b_id })), ), - ) - ).flat() - - const applications = allApplications.filter( - (app) => app.p_id === params.p_id, + ), ) - applications.sort( - (app1, app2) => - app1.p_app_date.getTime() - app2.p_app_date.getTime(), + const applicationsPerField = allApplicationsPerField.map( + (allApplications) => + allApplications + .filter((app) => app.p_id === params.p_id) + .sort( + (app1, app2) => + app1.p_app_date.getTime() - + app2.p_app_date.getTime(), + ), ) const fertilizerParameterDescription = @@ -91,24 +88,29 @@ export async function loader({ params, request }: Route.LoaderArgs) { if (!applicationMethods) throw new Error("Parameter metadata missing") const applicationsExtended = await Promise.all( - applications.map(async (application) => { - const canModify = await checkPermission( - fdm, - "fertilizer_application", - "write", - application.p_app_id, - session.principal_id, - "RotationTableFertilizerApplicationListDialog", - false, - ) - return { - ...application, - canModify: canModify, - p_app_method_name: applicationMethods.options?.find( - (option) => option.value === application.p_app_method, - )?.label, - } - }), + applicationsPerField.map((applications) => + Promise.all( + applications.map(async (application) => { + const canModify = await checkPermission( + fdm, + "fertilizer_application", + "write", + application.p_app_id, + session.principal_id, + "RotationTableFertilizerApplicationListDialog", + false, + ) + return { + ...application, + canModify: canModify, + p_app_method_name: applicationMethods.options?.find( + (option) => + option.value === application.p_app_method, + )?.label, + } + }), + ), + ), ) const returnUrl = `${url.pathname}${url.search}` @@ -125,7 +127,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { type ApplicationExtended = Awaited< ReturnType ->["fertilizerApplications"][number] +>["fertilizerApplications"][number][number] interface FertAppRecordItem { id: string dates: Date[] @@ -151,9 +153,11 @@ function mapByOrder( }) } -function groupAndOrderFertApps(applications: ApplicationExtended[]) { +function groupAndOrderFertApps(applicationsPerField: ApplicationExtended[][]) { const record: FertAppRecord = {} - mapByOrder(record, applications) + for (const group of applicationsPerField) { + mapByOrder(record, group) + } const entries = Object.entries(record).map( ([idx, reduced]) => @@ -172,23 +176,6 @@ function groupAndOrderFertApps(applications: ApplicationExtended[]) { return entries.map((ent) => ent[1]) } -// function combineRecords(records: FertAppRecordItem[]): FertAppRecordItem { -// const timestamps = [ -// ...new Set( -// records.flatMap((record) => -// record.dates.map((date) => date.getTime()), -// ), -// ), -// ] -// timestamps.sort() -// return { -// id: records[0].id, -// dates: timestamps.map((timestamp) => new Date(timestamp)), -// canModify: records.every(record => record.canModify), -// applications: records.flatMap((record) => record.applications), -// } -// } - function formatDateRange(dates: Date[]) { if (dates.length === 0) return "" const firstDate = dates[0] @@ -303,11 +290,14 @@ export default function FertilizerApplicationListDialog() { const navigate = useNavigate() const canModifyAnything = useMemo( - () => fertilizerApplications.some((app) => app.canModify), + () => + fertilizerApplications.some((apps) => + apps.some((app) => app.canModify), + ), [fertilizerApplications], ) - const record = groupAndOrderFertApps(fertilizerApplications) + const records = groupAndOrderFertApps(fertilizerApplications) return ( navigate("..")}> @@ -320,13 +310,15 @@ export default function FertilizerApplicationListDialog() { - Datum - Toedieningsmethode - Hoeveelheid - {canModifyAnything && } + + Datum + Toedieningsmethode + Hoeveelheid + {canModifyAnything && } + - {record.map((record) => ( + {records.map((record) => ( { - console.log("loading by cultivation and field ids") const timeframe = getTimeframe(params) const fields = await getFields( fdm, @@ -202,7 +204,7 @@ async function loadByCultivationAndFieldIds( selectedFieldIds: selectedFieldIds, canReselect: true, appIds: null, - exampleFertilizerApplication: {}, + exampleFertilizerApplication: undefined, fertilizerApplication: undefined, } } @@ -212,8 +214,6 @@ async function loadByAppIds( _params: PathParams, appIdPairs: string[], ): Promise { - console.log("loading by app ids") - const applicationRefs = parseAppIds(appIdPairs) const fields = await Promise.all( @@ -273,15 +273,6 @@ async function loadByAppIds( } } - console.log({ - fields: fields as StrategizedLoaderData["fields"], - selectedFieldIds: fields.map((field) => field.b_id), - canReselect: false, - appIds: fertilizerApplications.map((app) => app.p_app_id), - exampleFertilizerApplication: exampleFertilizerApplication, - fertilizerApplication: fertilizerApplication, - }) - return { fields: fields as StrategizedLoaderData["fields"], selectedFieldIds: fields.map((field) => field.b_id), @@ -419,10 +410,12 @@ export async function loader({ request, params }: Route.LoaderArgs) { cultivationIds: cultivationIds, canReselect: canReselect, exampleFertilizerApplication: exampleFertilizerApplication, - fertilizerApplication: { - ...fertilizerApplication, - p_app_ids: appIds, - }, + fertilizerApplication: fertilizerApplication + ? { + ...fertilizerApplication, + p_app_ids: appIds, + } + : undefined, create: url.searchParams.has("create"), } } catch (error) { @@ -843,22 +836,26 @@ export async function action({ request, params }: ActionFunctionArgs) { const validatedData = await extractFormValuesFromRequest( request, - FormSchema, + url.searchParams.has("appIds") ? FormSchemaModify : FormSchema, ) - const appIdPairs = url.searchParams - .get("appIds") - ?.split(",") - .filter(Boolean) - if (appIdPairs && appIdPairs.length > 0) { - const applicationRefs = parseAppIds(appIdPairs) + const returnUrlParam = url.searchParams.get("returnUrl") + const returnUrl = + returnUrlParam && isOfOrigin(returnUrlParam, url.origin) + ? returnUrlParam + : url.searchParams.has("create") + ? `/farm/create/${b_id_farm}/${calendar}/rotation` + : `/farm/${b_id_farm}/${calendar}/rotation` + + if (validatedData.p_app_id) { + const p_app_ids = validatedData.p_app_id.split(",").filter(Boolean) fdm.transaction((tx) => Promise.all( - applicationRefs.map((ref) => + p_app_ids.map((p_app_id) => updateFertilizerApplication( tx, session.principal_id, - ref.p_app_id, + p_app_id, validatedData.p_id, validatedData.p_app_amount, validatedData.p_app_method, @@ -868,17 +865,9 @@ export async function action({ request, params }: ActionFunctionArgs) { ), ) - const returnUrl = url.searchParams.get("returnUrl") - return redirectWithSuccess( - returnUrl && isOfOrigin(returnUrl, url.origin) - ? returnUrl - : url.searchParams.has("create") - ? `/farm/create/${b_id_farm}/${calendar}/rotation` - : `/farm/${b_id_farm}/${calendar}/rotation`, - { - message: `Bemesting succesvol toegevoegd aan ${fieldIds.length} ${fieldIds.length === 1 ? "perceel" : "percelen"}.`, - }, - ) + return redirectWithSuccess(returnUrl, { + message: `${p_app_ids.length} ${p_app_ids.length === 1 ? "bemesting is" : "bemestingen zijn"} succesvol bijgewerkt.`, + }) } const fieldIds = @@ -904,14 +893,9 @@ export async function action({ request, params }: ActionFunctionArgs) { ), ) - return redirectWithSuccess( - url.searchParams.has("create") - ? `/farm/create/${b_id_farm}/${calendar}/rotation` - : `/farm/${b_id_farm}/${calendar}/rotation`, - { - message: `Bemesting succesvol toegevoegd aan ${fieldIds.length} ${fieldIds.length === 1 ? "perceel" : "percelen"}.`, - }, - ) + return redirectWithSuccess(returnUrl, { + message: `Bemesting succesvol toegevoegd aan ${fieldIds.length} ${fieldIds.length === 1 ? "perceel" : "percelen"}.`, + }) } catch (error) { if (error instanceof z.ZodError) { return dataWithError( From b6f7cd52502ed5fa1e5fd4241011f1e4066f0006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Feb 2026 13:02:36 +0100 Subject: [PATCH 03/35] Add changeset --- .changeset/quick-dots-tease.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quick-dots-tease.md diff --git a/.changeset/quick-dots-tease.md b/.changeset/quick-dots-tease.md new file mode 100644 index 000000000..79c4c8507 --- /dev/null +++ b/.changeset/quick-dots-tease.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": minor +--- + +Users can click the fertilizer badges on the rotation table to see a table of applications of this fertilizer onto the clicked cultivation or field. They can edit the applications directly from this table. From 2f4b12e5dcdbfc72dd23124986ce8d7bf06da513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Feb 2026 13:35:29 +0100 Subject: [PATCH 04/35] Add empty table fallback and fix the routes for farm create wizard --- .../blocks/rotation/fertilizer-display.tsx | 2 +- ...endar.rotation.modify_fertilizer.$p_id.tsx | 78 +++++++++++++------ ...endar.rotation.modify_fertilizer.$p_id.tsx | 28 +++++++ 3 files changed, 82 insertions(+), 26 deletions(-) create mode 100644 fdm-app/app/routes/farm.create.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx diff --git a/fdm-app/app/components/blocks/rotation/fertilizer-display.tsx b/fdm-app/app/components/blocks/rotation/fertilizer-display.tsx index a7cad0709..2e14f7e11 100644 --- a/fdm-app/app/components/blocks/rotation/fertilizer-display.tsx +++ b/fdm-app/app/components/blocks/rotation/fertilizer-display.tsx @@ -7,7 +7,7 @@ import { TooltipTrigger, } from "~/components/ui/tooltip" import type { FieldRow, RotationExtended } from "./columns" -import { NavLink } from "react-router" +import { NavLink, useLocation, useParams } from "react-router" type FertilizerDisplayProps = { cultivation: RotationExtended diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx index f600d06f6..c997b87f8 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx @@ -8,7 +8,14 @@ import { import { format } from "date-fns" import { nl } from "date-fns/locale" import { useMemo } from "react" -import { data, Form, NavLink, useLoaderData, useNavigate } from "react-router" +import { + data, + Form, + NavLink, + useLoaderData, + useNavigate, + useParams, +} from "react-router" import { dataWithSuccess } from "remix-toast" import z from "zod" import { Button } from "~/components/ui/button" @@ -21,6 +28,12 @@ import { DialogHeader, DialogTitle, } from "~/components/ui/dialog" +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "~/components/ui/empty" import { Table, TableBody, @@ -32,7 +45,7 @@ import { import { getSession } from "~/lib/auth.server" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" -import { extractFormValuesFromRequest } from "../lib/form" +import { extractFormValuesFromRequest } from "~/lib/form" import type { Route } from "./+types/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id" interface FertilizerInfo { @@ -203,6 +216,8 @@ function FertilizerApplicationRow({ returnUrl: string includeModifyCellWhenReadonly: boolean }) { + const params = useParams() + const { dates, applicationMethods, @@ -256,7 +271,7 @@ function FertilizerApplicationRow({
- - - Datum - Toedieningsmethode - Hoeveelheid - {canModifyAnything && } - - - - {records.map((record) => ( - - ))} - -
+ {records.length > 0 ? ( + + + + Datum + Toedieningsmethode + Hoeveelheid + {canModifyAnything && } + + + + {records.map((record) => ( + + ))} + +
+ ) : ( + + + Geen bemestingen gevonden + + Het lijkt erop dat deze meststof niet langer op + dit perceel/deze percelen en gewassen wordt + toegepast. + + + + )} -
+ pairStr.split(":")) + .filter( + (pair) => + pair.length === 2 && pair[0].length > 0 && pair[1].length > 0, + ) + .map(([b_id, p_app_id]) => ({ b_id, p_app_id })) +} + const FormSchema = z.discriminatedUnion("intent", [ z.object({ intent: z.literal("remove_application"), appIds: z .string() - .transform((str) => str.split(",")) - .refine((ids) => ids.length > 0, { error: "missing: p_app_id" }), + .transform(parseAppIds) + .refine((ids) => ids.length > 0, { + error: "missing: appIds", + }), }), ]) @@ -389,7 +402,7 @@ export async function action({ request }: Route.ActionArgs) { if (formData.intent === "remove_application") { await fdm.transaction((tx) => Promise.all( - formData.appIds.map((p_app_id) => + formData.appIds.map(({ p_app_id }) => removeFertilizerApplication( tx, session.principal_id, From 2d4e755a7e3a6d0489f60ac7bb0f98aa5614ef04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Feb 2026 15:30:43 +0100 Subject: [PATCH 07/35] Add spinner when deleting fertilizer application --- ...m.$calendar.rotation.modify_fertilizer.$p_id.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx index 8138f767f..6b476f751 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx @@ -14,6 +14,7 @@ import { NavLink, useLoaderData, useNavigate, + useNavigation, useParams, } from "react-router" import { dataWithSuccess } from "remix-toast" @@ -46,6 +47,8 @@ import { getSession } from "~/lib/auth.server" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" +import { Spinner } from "../components/ui/spinner" +import { cn } from "../lib/utils" import type { Route } from "./+types/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id" interface FertilizerInfo { @@ -217,6 +220,7 @@ function FertilizerApplicationRow({ includeModifyCellWhenReadonly: boolean }) { const params = useParams() + const navigation = useNavigation() const { dates, @@ -268,7 +272,7 @@ function FertilizerApplicationRow({ {applicationMethods} {applicationAmount} {modifiableApps.length > 0 ? ( - + + ) : ( includeModifyCellWhenReadonly && From 2ef5eeeac9e4d0864d040783082885480382757c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Feb 2026 15:52:00 +0100 Subject: [PATCH 08/35] Nitpicks --- .../blocks/fertilizer-applications/form.tsx | 66 +++++++++---------- .../blocks/rotation/fertilizer-display.tsx | 16 ++--- ...endar.rotation.modify_fertilizer.$p_id.tsx | 2 +- ....$calendar.rotation_.fertilizer._index.tsx | 9 ++- 4 files changed, 49 insertions(+), 44 deletions(-) diff --git a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx index c98915c0f..dec464245 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx @@ -244,39 +244,6 @@ export function FertilizerApplicationForm({ )} /> - ( - - - {fieldState.invalid && ( - - )} - - )} - /> ( @@ -327,6 +294,39 @@ export function FertilizerApplicationForm({ )} /> + ( + + + {fieldState.invalid && ( + + )} + + )} + />
+ + + = 8 + ? "h-72 overflow-y-auto w-48" + : "w-48" + } + > +
+ {fieldNames.map( + ([b_id, b_name]) => ( + + {b_name} + + ), + )} +
+
+
+ + ) : ( +
{fieldNames[0][1]}
+ )} + + )} + {applicationMethods} {applicationAmount} - {modifiableApps.length > 0 ? ( - - -
- - -
- -
- ) : ( - includeModifyCellWhenReadonly && - )} +
+ + +
+ +
+ ) : ( + + ))} ) } @@ -337,14 +441,6 @@ export default function FertilizerApplicationListDialog() { const navigate = useNavigate() - const canModifyAnything = useMemo( - () => - fertilizerApplications.some((apps) => - apps.some((app) => app.canModify), - ), - [fertilizerApplications], - ) - const [rowMapper, setRowMapper] = useState("mapByDate") @@ -353,6 +449,16 @@ export default function FertilizerApplicationListDialog() { [fertilizerApplications, rowMapper], ) + const columnVisibility = useMemo( + () => ({ + count: records.some((app) => app.applications.length > 1), + modify: fertilizerApplications.some((apps) => + apps.some((app) => app.canModify), + ), + }), + [fertilizerApplications, records], + ) + return ( navigate("..")}> @@ -364,47 +470,55 @@ export default function FertilizerApplicationListDialog() { {records.length > 0 ? ( <> -
-

Groeperen op

- { - if (value in mappers) - setRowMapper( - value as keyof typeof mappers, - ) - }} - > - - - Datum - - - Volgorde - - - Niet groupen - - - -
+ {fertilizerApplications + .map((apps) => apps.length) + .reduce((a, b) => a + b) > 1 && ( +
+

Groeperen op

+ { + if (value in mappers) + setRowMapper( + value as keyof typeof mappers, + ) + }} + > + + + Datum + + + Volgorde + + + Niet groeperen + + + +
+ )} Datum + Percelen + {columnVisibility.count && ( + + Aantal Bemestingen + + )} Toedieningsmethode Hoeveelheid - {canModifyAnything && } + {columnVisibility.modify && } - + {records.map((record) => ( ))} 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 29d01d732..5c2c1c8b0 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 @@ -548,33 +548,34 @@ export default function FarmRotationFertilizerAddIndex() { {field.b_name}

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

- Dit - perceel - heeft - het - geselecteerde - gewas - niet -

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

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

+
+
+
+ )} {field.b_area}{" "} ha From 38bbdd8fbdecb71b9ae1169d71cd2be89b83d573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Tue, 24 Feb 2026 17:26:16 +0100 Subject: [PATCH 15/35] Add field names column and various conditional messages --- ...endar.rotation.modify_fertilizer.$p_id.tsx | 178 ++++++++++-------- ....$calendar.rotation_.fertilizer._index.tsx | 56 ++++-- 2 files changed, 139 insertions(+), 95 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx index b575b5a8c..971241cea 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx @@ -30,6 +30,12 @@ import { DialogHeader, DialogTitle, } from "~/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" import { Empty, EmptyDescription, @@ -46,18 +52,12 @@ import { TableHeader, TableRow, } from "~/components/ui/table" +import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs" import { getSession } from "~/lib/auth.server" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" import { cn } from "~/lib/utils" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "~/components/ui/dropdown-menu" -import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs" import type { Route } from "./+types/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id" interface FertilizerInfo { @@ -150,6 +150,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { const returnUrl = `${url.pathname}${url.search}` return { + fieldIds: fieldIds, fertilizer: fertilizer, fertilizerApplications: applicationsExtended, returnUrl: returnUrl, @@ -164,7 +165,6 @@ type ApplicationExtended = Awaited< >["fertilizerApplications"][number][number] interface FertAppRecordItem { id: string - dates: Date[] applications: ApplicationExtended[] } @@ -197,11 +197,9 @@ const createMapper = const key = keyExtractor(application, i) record[key] ??= { id: `${application.p_id}_${key}`, - dates: [], applications: [], } record[key].applications.push(application) - record[key].dates.push(application.p_app_date) }) } @@ -216,7 +214,6 @@ const mapEach: RowMapperFunction = (record, applications) => { const key = offset + i record[key] = { id: `${application.p_id}_${key}`, - dates: [application.p_app_date], applications: [application], } }) @@ -230,9 +227,18 @@ export const mappers = { mapByDate: createMapper((application) => application.p_app_date.getTime().toString(), ), - mapEach: mapEach, + mapByField: createMapper((application) => application.b_id), + mapEach: createMapper((application) => application.p_app_id), } as const +function compareDates(a: Date, b: Date) { + return a.getTime() - b.getTime() +} + +function compareStrings(a: string, b: string) { + return a < b ? -1 : a > b ? 1 : 0 +} + /** * * @param applicationsPerField @@ -257,10 +263,9 @@ function groupAndOrderFertApps( entries.forEach((ent) => { ent[1].applications.sort( (a, b) => - (a.p_app_date as Date).getTime() - - (b.p_app_date as Date).getTime(), + compareDates(a.p_app_date as Date, b.p_app_date as Date) || + compareStrings(a.b_name, b.b_name), ) - ent[1].dates.sort((a, b) => a.getTime() - b.getTime()) }) return entries.map((ent) => ent[1]) } @@ -291,7 +296,7 @@ function FertilizerApplicationRow({ }: { record: FertAppRecordItem returnUrl: string - columnVisibility: Record<"count" | "modify", boolean> + columnVisibility: Record<"fieldName" | "count" | "modify", boolean> }) { const params = useParams() const navigation = useNavigation() @@ -304,13 +309,15 @@ function FertilizerApplicationRow({ modifiableApps, modifiableAppIds, } = useMemo(() => { - const dates = formatDateRange(record.dates) + const dates = formatDateRange( + record.applications.map((app) => app.p_app_date), + ) // Gets names of distinct fields const fieldNames = Object.entries( Object.fromEntries( record.applications.map((app) => [app.b_id, app.b_name]), ), - ).sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)) + ).sort((a, b) => compareStrings(a[1], b[1])) const applicationMethods = [ ...new Set( record.applications @@ -351,53 +358,57 @@ function FertilizerApplicationRow({ return ( {dates} - - {columnVisibility.count && ( - - {record.applications.length > 1 ? ( - - - - - - = 8 - ? "h-72 overflow-y-auto w-48" - : "w-48" - } - > -
- {fieldNames.map( - ([b_id, b_name]) => ( - - {b_name} - - ), - )} -
-
-
-
- ) : ( -
{fieldNames[0][1]}
- )} -
- )} -
+ {columnVisibility.count && ( + {record.applications.length} + )} + {columnVisibility.fieldName && ( + + {record.applications.length > 1 ? ( + + + + + + = 8 + ? "h-72 overflow-y-auto w-48" + : "w-48" + } + > +
+ {fieldNames.map(([b_id, b_name]) => ( + + {b_name} + + ))} +
+
+
+
+ ) : ( + fieldNames[0][1] + )} +
+ )} {applicationMethods} {applicationAmount} {columnVisibility.modify && (modifiableApps.length > 0 ? ( + - + {loaderData.canReselect && ( + + + + )} @@ -789,9 +805,13 @@ export default function FarmRotationFertilizerAddIndex() { {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.`} + : isModification + ? loaderData.fieldAmount === 1 + ? "Werk de bemesting van het geselecteerde perceel bij." + : `Werk de bemesting van de ${loaderData.fieldAmount} geselecteerde percelen bij.` + : loaderData.fieldAmount === 1 + ? "Voeg een nieuwe bemestingstoepassing toe aan het geselecteerde perceel." + : `Voeg een nieuwe bemestingstoepassing toe aan de ${loaderData.fieldAmount} geselecteerde percelen.`} From 484e7dbef49f5e4574ab05c15b04ad3432ab1453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Wed, 25 Feb 2026 15:34:29 +0100 Subject: [PATCH 16/35] Use react table for the fertilizer application table and add it to the fields table page too --- .../fertilizer-applications/columns.tsx | 207 +++++++++ .../blocks/fertilizer-applications/table.tsx | 254 +++++++++++ .../app/components/blocks/fields/columns.tsx | 36 +- ...calendar.field.modify_fertilizer.$p_id.tsx | 28 ++ ...sx => farm.$b_id_farm.$calendar.field.tsx} | 29 ++ ...endar.rotation.modify_fertilizer.$p_id.tsx | 426 +----------------- 6 files changed, 560 insertions(+), 420 deletions(-) create mode 100644 fdm-app/app/components/blocks/fertilizer-applications/columns.tsx create mode 100644 fdm-app/app/components/blocks/fertilizer-applications/table.tsx create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.field.modify_fertilizer.$p_id.tsx rename fdm-app/app/routes/{farm.$b_id_farm.$calendar.field._index.tsx => farm.$b_id_farm.$calendar.field.tsx} (91%) diff --git a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx new file mode 100644 index 000000000..e239835f1 --- /dev/null +++ b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx @@ -0,0 +1,207 @@ +import type { FertilizerApplication } from "@nmi-agro/fdm-core" +import type { ColumnDef } from "@tanstack/react-table" +import { format } from "date-fns" +import { nl } from "date-fns/locale" +import { useMemo } from "react" +import { Form, NavLink, useNavigation, useParams } from "react-router" +import { cn } from "@/app/lib/utils" +import { Button } from "~/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" +import { Field } from "~/components/ui/field" +import { ScrollArea } from "~/components/ui/scroll-area" +import { Spinner } from "~/components/ui/spinner" + +export type ApplicationExtended = FertilizerApplication & { + b_id: string + b_name: string + p_app_method_name: string | null | undefined + canModify: boolean +} + +export interface FertAppRecordItem { + id: string + applications: ApplicationExtended[] +} + +/** + * Stringifies a single date if the given range is all the same dates, + * otherwise stringifies the earliest and the latest date with a dash in between. + * + * @param dates array of dates. Nulls and undefined items are not allowed. + * @returns the formatted string. + */ +function formatDateRange(dates: Date[]) { + if (dates.length === 0) return "" + const firstDate = dates[0] + const lastDate = dates[dates.length - 1] + return firstDate.getTime() === lastDate.getTime() + ? `${format(firstDate, "PP", { locale: nl })}` + : `${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}` +} + +/** + * Stringifies a single number with an unit if the given range is all the same numbers or very close down to roughly 2 decimal places, + * otherwise stringifies the least and the greatest numbers with a dash in between and the unit at the end. + * + * @param dates array of numbers. Nulls and undefined items are not allowed. + * @returns the formatted string. + */ +function formatNumberRange(numbers: number[], unit = "") { + if (numbers.length === 0) return "" + const firstNumber = numbers[0] + const lastNumber = numbers[numbers.length - 1] + return firstNumber === lastNumber || + Math.abs(lastNumber - firstNumber) < Math.abs(firstNumber) / 100 + ? `${firstNumber} ${unit}` + : `${firstNumber} - ${lastNumber} ${unit}` +} + +export const columns: ColumnDef[] = [ + { + id: "p_app_date", + header: "Datum", + cell: ({ row }) => + formatDateRange( + row.original.applications.map((app) => app.p_app_date), + ), + }, + { + accessorKey: "applications.length", + header: "Aantal Bemestingen", + }, + { + id: "b_name", + header: "Perceel", + cell: ({ row }) => { + const fieldNames = useMemo( + () => + Object.entries( + Object.fromEntries( + row.original.applications.map((app) => [ + app.b_id, + app.b_name, + ]), + ), + ).sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)), + [row.original], + ) + return row.original.applications.length > 1 ? ( + + + + + + = 8 + ? "h-72 overflow-y-auto w-48" + : "w-48" + } + > +
+ {fieldNames.map(([b_id, b_name]) => ( + + {b_name} + + ))} +
+
+
+
+ ) : ( + fieldNames[0][1] + ) + }, + }, + { + id: "p_app_method", + header: "Toedieningsmethode", + cell: ({ row }) => + [ + ...new Set( + row.original.applications + .map( + (application) => + application.p_app_method_name ?? + application.p_app_method, + ) + .filter((date) => date !== null), + ), + ] + .sort() + .join(", "), + }, + { + id: "p_app_amount", + header: "Hoeveelheid", + cell: ({ row }) => + formatNumberRange( + row.original.applications + .map((application) => application.p_app_amount) + .filter((amount) => amount !== null) + .sort((a, b) => a - b), + "kg / ha", + ), + }, + { + id: "modify", + header: "", + cell: ({ returnUrl, row }) => { + const params = useParams() + const navigation = useNavigation() + + const modifiableAppIds = useMemo( + () => + row.original.applications + .filter((app) => app.canModify) + .map((app) => `${app.b_id}:${app.p_app_id}`) + .join(","), + [row.original], + ) + + return ( + + + +
+ + + +
+ ) + }, + }, +] diff --git a/fdm-app/app/components/blocks/fertilizer-applications/table.tsx b/fdm-app/app/components/blocks/fertilizer-applications/table.tsx new file mode 100644 index 000000000..1941c9f7b --- /dev/null +++ b/fdm-app/app/components/blocks/fertilizer-applications/table.tsx @@ -0,0 +1,254 @@ +import { + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" +import { useMemo, useState } from "react" +import { cn } from "@/app/lib/utils" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table" +import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs" +import { + type ApplicationExtended, + columns, + type FertAppRecordItem, +} from "./columns" + +type FertAppRecord = Record + +/** + * Creates a mapper function that places applications into table rows. + * + * @param keyExtractor given a fertilizer application an index unique within the field's fertilizer + * applications group, it should return a stringified number that will determine which row the + * application goes into. + * @returns the mapper function + */ +const createMapper = + ( + keyExtractor: ( + application: Omit & { + p_app_date: Date + }, + i: number, + ) => string, + ) => + (record: FertAppRecord, applications: ApplicationExtended[]) => { + applications.forEach((application, i) => { + if (!application.p_app_date) return + const key = keyExtractor(application, i) + record[key] ??= { + id: `${application.p_id}_${key}`, + applications: [], + } + record[key].applications.push(application) + }) + } + +/** + * Mapper functions that can be used to group the fertilizer applications into table rows in different ways + */ +export const mappers = { + mapByOrder: createMapper((_application, i) => i.toString()), + mapByDate: createMapper((application) => + application.p_app_date.getTime().toString(), + ), + mapByField: createMapper((application) => application.b_id), + mapEach: createMapper((application) => application.p_app_id), +} as const + +const keysToSortById: (keyof typeof mappers)[] = ["mapByOrder", "mapByDate"] + +function compareDates(a: Date, b: Date) { + return a.getTime() - b.getTime() +} + +function compareStrings(a: string, b: string) { + return a < b ? -1 : a > b ? 1 : 0 +} + +/** + * Groups fertilizer applications into table rows using the mapper strategy + * + * Some mappers will come up with an ordering of the rows. The function will + * try to sort by date and field name otherwise. + * + * @param applicationsPerField + * @param mapper + * @returns + */ +function groupAndOrderFertApps( + applicationsPerField: ApplicationExtended[][], + mapper: keyof typeof mappers, +) { + const record: FertAppRecord = {} + for (const group of applicationsPerField) { + mappers[mapper](record, group) + } + + const unsortedEntries = Object.entries(record) + + // Applications with no date get filtered out in the mapping function + unsortedEntries.forEach((ent) => { + ent[1].applications.sort( + (a, b) => + compareDates(a.p_app_date as Date, b.p_app_date as Date) || + compareStrings(a.b_name, b.b_name), + ) + }) + + const entries = keysToSortById.includes(mapper) + ? // There is an ordering imposed by the mapper which is the same as the ordering of the keys + unsortedEntries + .map( + ([key, value]) => + [Number.parseFloat(key), value] as [ + number, + FertAppRecordItem, + ], + ) + .sort((a, b) => a[0] - b[0]) + : // There is no inherent ordering, sort by date and field name of first application + unsortedEntries.sort( + (a, b) => + compareDates( + a[1].applications[0].p_app_date as Date, + b[1].applications[0].p_app_date as Date, + ) || + compareStrings( + a[1].applications[0].b_name, + b[1].applications[0].b_name, + ), + ) + + return entries.map((ent) => ent[1]) +} + +export function DataTable({ + numFields, + fertilizerApplications, + returnUrl, +}: { + numFields: number + fertilizerApplications: ApplicationExtended[][] + returnUrl: string +}) { + const [rowMapper, setRowMapper] = + useState("mapByDate") + + const numFertilizerApplications = fertilizerApplications + .map((apps) => apps.length) + .reduce((a, b) => a + b) + + const shouldShowGroupingSelector = + numFields > 1 && numFertilizerApplications > 1 + + const records = useMemo( + () => groupAndOrderFertApps(fertilizerApplications, rowMapper), + [fertilizerApplications, rowMapper], + ) + + const columnVisibility = useMemo( + () => ({ + b_name: numFields > 1, + "applications.length": records.some( + (app) => app.applications.length > 1, + ), + modify: fertilizerApplications.some((apps) => + apps.some((app) => app.canModify), + ), + }), + [numFields, fertilizerApplications, records], + ) + + const table = useReactTable({ + columns: columns, + data: records, + getCoreRowModel: getCoreRowModel(), + state: { + columnVisibility: columnVisibility, + }, + }) + + return ( + <> + {shouldShowGroupingSelector && ( +
+

+ Groeperen op{" "} +

+ { + if (value in mappers) + setRowMapper(value as keyof typeof mappers) + }} + > + + Datum + + Volgorde + + + Niet groeperen + + + +
+ )} +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext(), + )} + + ) + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, { + ...cell.getContext(), + returnUrl: returnUrl, + })} + + ))} + + ))} + +
+ + ) +} diff --git a/fdm-app/app/components/blocks/fields/columns.tsx b/fdm-app/app/components/blocks/fields/columns.tsx index 114cf067c..fd2547a0e 100644 --- a/fdm-app/app/components/blocks/fields/columns.tsx +++ b/fdm-app/app/components/blocks/fields/columns.tsx @@ -143,24 +143,28 @@ export const columns: ColumnDef[] = [ return (
{fertilizers.map((fertilizer) => ( - - - {fertilizer.p_type === "manure" ? ( - - ) : fertilizer.p_type === "mineral" ? ( - - ) : fertilizer.p_type === "compost" ? ( - - ) : ( - - )} - - {fertilizer.p_name_nl} - + + + {fertilizer.p_type === "manure" ? ( + + ) : fertilizer.p_type === "mineral" ? ( + + ) : fertilizer.p_type === "compost" ? ( + + ) : ( + + )} + + {fertilizer.p_name_nl} + + ))}
) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.modify_fertilizer.$p_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.modify_fertilizer.$p_id.tsx new file mode 100644 index 000000000..5d954306d --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.modify_fertilizer.$p_id.tsx @@ -0,0 +1,28 @@ +import type { MetaFunction } from "react-router" +import { clientConfig } from "~/lib/config" +import type { Route as UpstreamRoute } from "./+types/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id" +import ModifyFertilizer, { + action as originalAction, + loader as originalLoader, +} from "./farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id" + +// Meta +export const meta: MetaFunction = () => { + return [ + { title: `Bouwplan - Bedrijf toevoegen | ${clientConfig.name}` }, + { + name: "description", + content: "Beheer de gewassen op je percelen.", + }, + ] +} + +export async function loader(props: UpstreamRoute.LoaderArgs) { + return originalLoader(props) +} + +export default ModifyFertilizer + +export async function action(props: UpstreamRoute.ActionArgs) { + return originalAction(props) +} 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.tsx similarity index 91% rename from fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx rename to fdm-app/app/routes/farm.$b_id_farm.$calendar.field.tsx index 62c5d2ecb..f7aa7c177 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.tsx @@ -12,6 +12,7 @@ import { type LoaderFunctionArgs, type MetaFunction, NavLink, + Outlet, redirect, useLoaderData, } from "react-router" @@ -63,6 +64,28 @@ export const meta: MetaFunction = () => { */ export async function loader({ request, params }: LoaderFunctionArgs) { try { + // This route is a layout to be able to show dialogs on top of the table + // An empty layout should be rendered for irrelevant routes + const url = new URL(request.url) + if (params.b_id_farm && params.calendar) { + const base = `/farm/${params.b_id_farm}/${params.calendar}/field` + + const toTest = url.pathname.endsWith("/") + ? url.pathname.substring(0, url.pathname.length - 1) + : url.pathname + if (toTest !== base && toTest !== `${base}/modify_fertilizer`) { + return { + shouldShowLayout: false, + b_id_farm: params.b_id_farm, + farmOptions: [], + fieldOptions: [], + fieldsExtended: [], + userName: "", + farmWritePermission: false, + } + } + } + // Get the active farm const b_id_farm = params.b_id_farm if (!b_id_farm) { @@ -192,6 +215,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // Return user information from loader return { + shouldShowLayout: true, b_id_farm: b_id_farm, farmOptions: farmOptions, fieldOptions: fieldOptions, @@ -231,6 +255,10 @@ export default function FarmFieldIndex() { (farm) => farm.b_id_farm === loaderData.b_id_farm, )?.b_name_farm ?? "" + if (!loaderData.shouldShowLayout) { + return + } + return (
)} +
) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx index 971241cea..e4c805b90 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx @@ -6,20 +6,10 @@ import { getField, removeFertilizerApplication, } from "@nmi-agro/fdm-core" -import { format } from "date-fns" -import { nl } from "date-fns/locale" -import { useMemo, useState } from "react" -import { - data, - Form, - NavLink, - useLoaderData, - useNavigate, - useNavigation, - useParams, -} from "react-router" +import { data, useLoaderData, useNavigate } from "react-router" import { dataWithSuccess } from "remix-toast" import z from "zod" +import { DataTable } from "~/components/blocks/fertilizer-applications/table" import { Button } from "~/components/ui/button" import { Dialog, @@ -30,34 +20,16 @@ import { DialogHeader, DialogTitle, } from "~/components/ui/dialog" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "~/components/ui/dropdown-menu" import { Empty, EmptyDescription, EmptyHeader, EmptyTitle, } from "~/components/ui/empty" -import { ScrollArea } from "~/components/ui/scroll-area" -import { Spinner } from "~/components/ui/spinner" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "~/components/ui/table" -import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs" import { getSession } from "~/lib/auth.server" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" -import { cn } from "~/lib/utils" import type { Route } from "./+types/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id" interface FertilizerInfo { @@ -150,7 +122,8 @@ export async function loader({ params, request }: Route.LoaderArgs) { const returnUrl = `${url.pathname}${url.search}` return { - fieldIds: fieldIds, + isForRotation: !url.pathname.includes("/field/"), + numFields: fieldIds.length, fertilizer: fertilizer, fertilizerApplications: applicationsExtended, returnUrl: returnUrl, @@ -160,403 +133,48 @@ export async function loader({ params, request }: Route.LoaderArgs) { } } -type ApplicationExtended = Awaited< - ReturnType ->["fertilizerApplications"][number][number] -interface FertAppRecordItem { - id: string - applications: ApplicationExtended[] -} - -type FertAppRecord = Record - -type RowMapperFunction = ( - record: FertAppRecord, - applications: ApplicationExtended[], -) => void -/** - * Creates a mapper function that places applications into table rows. - * - * @param keyExtractor given a fertilizer application an index unique within the field's fertilizer - * applications group, it should return a stringified number that will determine which row the - * application goes into. - * @returns the mapper function - */ -const createMapper = - ( - keyExtractor: ( - application: Omit & { - p_app_date: Date - }, - i: number, - ) => string, - ) => - (record: FertAppRecord, applications: ApplicationExtended[]) => { - applications.forEach((application, i) => { - if (!application.p_app_date) return - const key = keyExtractor(application, i) - record[key] ??= { - id: `${application.p_id}_${key}`, - applications: [], - } - record[key].applications.push(application) - }) - } - -/** This mapper function maps each application as a separate entry. */ -const mapEach: RowMapperFunction = (record, applications) => { - const offset = Object.keys(record).length - applications - .filter((application) => application.p_app_date != null) - .sort((a, b) => a.p_app_date.getTime() - b.p_app_date.getTime()) - .forEach((application, i) => { - if (!application.p_app_date) return - const key = offset + i - record[key] = { - id: `${application.p_id}_${key}`, - applications: [application], - } - }) -} - -/** - * Mapper functions that can be used to group the fertilizer applications into table rows in different ways - */ -export const mappers = { - mapByOrder: createMapper((_application, i) => i.toString()), - mapByDate: createMapper((application) => - application.p_app_date.getTime().toString(), - ), - mapByField: createMapper((application) => application.b_id), - mapEach: createMapper((application) => application.p_app_id), -} as const - -function compareDates(a: Date, b: Date) { - return a.getTime() - b.getTime() -} - -function compareStrings(a: string, b: string) { - return a < b ? -1 : a > b ? 1 : 0 -} - -/** - * - * @param applicationsPerField - * @param mapper - * @returns - */ -function groupAndOrderFertApps( - applicationsPerField: ApplicationExtended[][], - mapper: RowMapperFunction, -) { - const record: FertAppRecord = {} - for (const group of applicationsPerField) { - mapper(record, group) - } - - const entries = Object.entries(record).map( - ([idx, reduced]) => - [Number.parseFloat(idx), reduced] as [number, typeof reduced], - ) - entries.sort((a, b) => a[0] - b[0]) - // Applications with no date get filtered out in the mapping function - entries.forEach((ent) => { - ent[1].applications.sort( - (a, b) => - compareDates(a.p_app_date as Date, b.p_app_date as Date) || - compareStrings(a.b_name, b.b_name), - ) - }) - return entries.map((ent) => ent[1]) -} - -function formatDateRange(dates: Date[]) { - if (dates.length === 0) return "" - const firstDate = dates[0] - const lastDate = dates[dates.length - 1] - return firstDate.getTime() === lastDate.getTime() - ? `${format(firstDate, "PP", { locale: nl })}` - : `${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}` -} - -function formatNumberRange(numbers: number[], unit = "") { - if (numbers.length === 0) return "" - const firstNumber = numbers[0] - const lastNumber = numbers[numbers.length - 1] - return firstNumber === lastNumber || - Math.abs(lastNumber - firstNumber) < Math.abs(firstNumber) / 100 - ? `${firstNumber} ${unit}` - : `${firstNumber} - ${lastNumber} ${unit}` -} - -function FertilizerApplicationRow({ - record, - returnUrl, - columnVisibility, -}: { - record: FertAppRecordItem - returnUrl: string - columnVisibility: Record<"fieldName" | "count" | "modify", boolean> -}) { - const params = useParams() - const navigation = useNavigation() - - const { - dates, - fieldNames, - applicationMethods, - applicationAmount, - modifiableApps, - modifiableAppIds, - } = useMemo(() => { - const dates = formatDateRange( - record.applications.map((app) => app.p_app_date), - ) - // Gets names of distinct fields - const fieldNames = Object.entries( - Object.fromEntries( - record.applications.map((app) => [app.b_id, app.b_name]), - ), - ).sort((a, b) => compareStrings(a[1], b[1])) - const applicationMethods = [ - ...new Set( - record.applications - .map( - (application) => - application.p_app_method_name ?? - application.p_app_method, - ) - .filter((date) => date !== null), - ), - ] - .sort() - .join(", ") - const applicationAmount = formatNumberRange( - record.applications - .map((application) => application.p_app_amount) - .filter((amount) => amount !== null) - .sort((a, b) => a - b), - "kg / ha", - ) - const modifiableApps = record.applications.filter( - (app) => app.canModify, - ) - const modifiableAppIds = modifiableApps - .map((app) => `${app.b_id}:${app.p_app_id}`) - .join(",") - - return { - dates, - fieldNames, - applicationMethods, - applicationAmount, - modifiableApps, - modifiableAppIds, - } - }, [record]) - - return ( - - {dates} - {columnVisibility.count && ( - {record.applications.length} - )} - {columnVisibility.fieldName && ( - - {record.applications.length > 1 ? ( - - - - - - = 8 - ? "h-72 overflow-y-auto w-48" - : "w-48" - } - > -
- {fieldNames.map(([b_id, b_name]) => ( - - {b_name} - - ))} -
-
-
-
- ) : ( - fieldNames[0][1] - )} -
- )} - {applicationMethods} - {applicationAmount} - {columnVisibility.modify && - (modifiableApps.length > 0 ? ( - - - -
- - -
-
- ) : ( - - ))} -
- ) -} - export default function FertilizerApplicationListDialog() { - const { fieldIds, fertilizer, fertilizerApplications, returnUrl } = - useLoaderData() + const { + isForRotation, + numFields, + fertilizer, + fertilizerApplications, + returnUrl, + } = useLoaderData() const navigate = useNavigate() - const [rowMapper, setRowMapper] = - useState("mapByDate") - const numFertilizerApplications = fertilizerApplications .map((apps) => apps.length) .reduce((a, b) => a + b) - const shouldShowGroupingSelector = - fieldIds.length > 1 && numFertilizerApplications > 1 - - const rowMapperToUse = shouldShowGroupingSelector ? rowMapper : "mapEach" - - const records = useMemo( - () => - groupAndOrderFertApps( - fertilizerApplications, - mappers[rowMapperToUse], - ), - [fertilizerApplications, rowMapperToUse], - ) - - const columnVisibility = useMemo( - () => ({ - fieldName: fieldIds.length > 1, - count: records.some((app) => app.applications.length > 1), - modify: fertilizerApplications.some((apps) => - apps.some((app) => app.canModify), - ), - }), - [fieldIds, fertilizerApplications, records], - ) - return ( navigate("..")}> {fertilizer.p_name_nl}{" "} - {fieldIds.length === 1 && - ` op ${records[0].applications[0].b_name}`} + {numFields === 1 && + ` op ${fertilizerApplications.find((apps) => apps.length > 0)?.[0].b_name}`} Bekijk en beheer de bemestingen met deze meststof. - {records.length > 0 ? ( - <> - {shouldShowGroupingSelector && ( -
-

- Groeperen op{" "} -

- { - if (value in mappers) - setRowMapper( - value as keyof typeof mappers, - ) - }} - > - - - Datum - - - Volgorde - - - Niet groeperen - - - -
- )} - - - - Datum - {columnVisibility.count && ( - - Aantal Bemestingen - - )} - {columnVisibility.fieldName && ( - Percelen - )} - Toedieningsmethode - Hoeveelheid - {columnVisibility.modify && } - - - - {records.map((record) => ( - - ))} - -
- + {numFertilizerApplications > 0 ? ( + ) : ( Geen bemestingen gevonden - Het lijkt erop dat deze meststof niet langer op - dit perceel/deze percelen en gewassen wordt - toegepast. + {isForRotation + ? "Het lijkt erop dat deze meststof niet langer op dit perceel/deze percelen en gewassen wordt toegepast." + : "Het lijkt erop dat deze meststof niet langer op dit perceel wordt toegepast."} From 38fda1086bac5de940be747dc745d84392289273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Wed, 25 Feb 2026 16:45:54 +0100 Subject: [PATCH 17/35] Nitpicks --- .../fertilizer-applications/columns.tsx | 18 +++--- .../blocks/fertilizer-applications/form.tsx | 7 ++- .../blocks/fertilizer-applications/table.tsx | 11 ++-- ...calendar.field.modify_fertilizer.$p_id.tsx | 13 ----- .../farm.$b_id_farm.$calendar.field.tsx | 5 +- ...endar.rotation.modify_fertilizer.$p_id.tsx | 14 +---- ....$calendar.rotation_.fertilizer._index.tsx | 57 +++++++++---------- ...endar.rotation.modify_fertilizer.$p_id.tsx | 13 ----- 8 files changed, 54 insertions(+), 84 deletions(-) diff --git a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx index e239835f1..aa68d8f92 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx @@ -3,7 +3,7 @@ import type { ColumnDef } from "@tanstack/react-table" import { format } from "date-fns" import { nl } from "date-fns/locale" import { useMemo } from "react" -import { Form, NavLink, useNavigation, useParams } from "react-router" +import { NavLink, useFetcher, useParams } from "react-router" import { cn } from "@/app/lib/utils" import { Button } from "~/components/ui/button" import { @@ -157,9 +157,11 @@ export const columns: ColumnDef[] = [ { id: "modify", header: "", - cell: ({ returnUrl, row }) => { + cell: ({ row, table }) => { const params = useParams() - const navigation = useNavigation() + const fetcher = useFetcher() + const returnUrl = (table.options.meta as { returnUrl: string }) + .returnUrl const modifiableAppIds = useMemo( () => @@ -175,17 +177,17 @@ export const columns: ColumnDef[] = [ -
+ [] = [ name="intent" variant="destructive" value="remove_application" - disabled={navigation.state === "submitting"} + disabled={fetcher.state === "submitting"} > Verwijderen - +
) }, diff --git a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx index dac66cc25..172b69c9b 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx @@ -140,10 +140,11 @@ export function FertilizerApplicationForm({ ]) useEffect(() => { - if (fertilizerApplication?.p_app_amount) { - form.setValue("p_app_amount", fertilizerApplication.p_app_amount) + const p_app_amount = fertilizerApplication?.p_app_amount + if (p_app_amount !== null && typeof p_app_amount !== "undefined") { + form.setValue("p_app_amount", p_app_amount) } - }, [fertilizerApplication, form.setValue]) + }, [fertilizerApplication?.p_app_amount, form.setValue]) // Change fertilizer selection if the user has added a new fertilizer const new_p_id = searchParams.get("p_id") diff --git a/fdm-app/app/components/blocks/fertilizer-applications/table.tsx b/fdm-app/app/components/blocks/fertilizer-applications/table.tsx index 1941c9f7b..f74fb4adf 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/table.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/table.tsx @@ -144,7 +144,7 @@ export function DataTable({ const numFertilizerApplications = fertilizerApplications .map((apps) => apps.length) - .reduce((a, b) => a + b) + .reduce((a, b) => a + b, 0) const shouldShowGroupingSelector = numFields > 1 && numFertilizerApplications > 1 @@ -171,6 +171,7 @@ export function DataTable({ columns: columns, data: records, getCoreRowModel: getCoreRowModel(), + meta: { returnUrl }, state: { columnVisibility: columnVisibility, }, @@ -239,10 +240,10 @@ export function DataTable({ cell.column.id === "modify", })} > - {flexRender(cell.column.columnDef.cell, { - ...cell.getContext(), - returnUrl: returnUrl, - })} + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )}
))} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.modify_fertilizer.$p_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.modify_fertilizer.$p_id.tsx index 5d954306d..ff68355dd 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.modify_fertilizer.$p_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.modify_fertilizer.$p_id.tsx @@ -1,22 +1,9 @@ -import type { MetaFunction } from "react-router" -import { clientConfig } from "~/lib/config" import type { Route as UpstreamRoute } from "./+types/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id" import ModifyFertilizer, { action as originalAction, loader as originalLoader, } from "./farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id" -// Meta -export const meta: MetaFunction = () => { - return [ - { title: `Bouwplan - Bedrijf toevoegen | ${clientConfig.name}` }, - { - name: "description", - content: "Beheer de gewassen op je percelen.", - }, - ] -} - export async function loader(props: UpstreamRoute.LoaderArgs) { return originalLoader(props) } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.tsx index f7aa7c177..bbdea4061 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.tsx @@ -73,7 +73,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const toTest = url.pathname.endsWith("/") ? url.pathname.substring(0, url.pathname.length - 1) : url.pathname - if (toTest !== base && toTest !== `${base}/modify_fertilizer`) { + if ( + toTest !== base && + !toTest.startsWith(`${base}/modify_fertilizer`) + ) { return { shouldShowLayout: false, b_id_farm: params.b_id_farm, diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx index e4c805b90..b8e087798 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx @@ -29,6 +29,7 @@ import { import { getSession } from "~/lib/auth.server" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" +import { parseAppIds } from "~/lib/fertilizer-application-helpers" import { extractFormValuesFromRequest } from "~/lib/form" import type { Route } from "./+types/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id" @@ -146,7 +147,7 @@ export default function FertilizerApplicationListDialog() { const numFertilizerApplications = fertilizerApplications .map((apps) => apps.length) - .reduce((a, b) => a + b) + .reduce((a, b) => a + b, 0) return ( navigate("..")}> @@ -191,17 +192,6 @@ export default function FertilizerApplicationListDialog() { ) } -function parseAppIds(value: string) { - return value - .split(",") - .map((pairStr) => pairStr.split(":")) - .filter( - (pair) => - pair.length === 2 && pair[0].length > 0 && pair[1].length > 0, - ) - .map(([b_id, p_app_id]) => ({ b_id, p_app_id })) -} - const FormSchema = z.discriminatedUnion("intent", [ z.object({ intent: z.literal("remove_application"), 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 916581469..8a8210d8d 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 @@ -80,6 +80,7 @@ import { getCalendar, getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" +import { parseAppIds } from "~/lib/fertilizer-application-helpers" import { extractFormValuesFromRequest } from "~/lib/form" import { isOfOrigin, modifySearchParams } from "~/lib/url-utils" import type { Route } from "./+types/farm.$b_id_farm.$calendar.rotation_.fertilizer._index" @@ -94,24 +95,6 @@ export const meta: MetaFunction = () => { ] } -function parseAppIds( - appIdPairs: string[], -): { b_id: string; p_app_id: string }[] { - const applicationRefs: { p_app_id: string; b_id: string }[] = [] - - // Parse application references - for (const appId of appIdPairs) { - const splitting = appId.split(":") - if (splitting.length < 2) { - throw new Error(`invalid b_id:p_app_id : ${appId}`) - } - const [b_id, p_app_id] = splitting - applicationRefs.push({ b_id, p_app_id }) - } - - return applicationRefs -} - type PathParams = Route.LoaderArgs["params"] interface StrategizedLoaderData { fields: (Field & { cultivations?: string[] })[] @@ -126,11 +109,17 @@ function loadByStrategy( params: PathParams, searchParams: URLSearchParams, ) { - // Get cultivationIds from search params - const appIds = searchParams.get("appIds")?.split(",").filter(Boolean) - - if (appIds && appIds.length > 0) { - return loadByAppIds(principal_id, params, appIds) + if (searchParams.has("appIds")) { + // Get appIds from search params + const appIds = searchParams.get("appIds") ?? "" + const appIdPairs = parseAppIds(appIds) + if (appIdPairs.length === 0) { + throw data("invalid: appIds", { + status: 400, + statusText: "invalid: appIds", + }) + } + return loadByAppIds(principal_id, params, appIdPairs) } // Get cultivationIds from search params @@ -212,10 +201,8 @@ async function loadByCultivationAndFieldIds( async function loadByAppIds( principal_id: string, _params: PathParams, - appIdPairs: string[], + applicationRefs: ReturnType, ): Promise { - const applicationRefs = parseAppIds(appIdPairs) - const fieldIds = [...new Set(applicationRefs.map((ref) => ref.b_id))] const fields = await Promise.all( @@ -488,6 +475,8 @@ export default function FarmRotationFertilizerAddIndex() { } const isModification = !!loaderData.fertilizerApplication?.p_app_ids + const returnUrl = searchParams.get("returnUrl") + const comingFromFieldsTable = returnUrl?.includes("/field/") return ( @@ -506,7 +495,7 @@ export default function FarmRotationFertilizerAddIndex() { /> - Bouwplan + {comingFromFieldsTable ? "Percelen" : "Bouwplan"} @@ -517,8 +506,18 @@ export default function FarmRotationFertilizerAddIndex() {
{isSubmitting && ( diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx index 5d954306d..ff68355dd 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx @@ -1,22 +1,9 @@ -import type { MetaFunction } from "react-router" -import { clientConfig } from "~/lib/config" import type { Route as UpstreamRoute } from "./+types/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id" import ModifyFertilizer, { action as originalAction, loader as originalLoader, } from "./farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id" -// Meta -export const meta: MetaFunction = () => { - return [ - { title: `Bouwplan - Bedrijf toevoegen | ${clientConfig.name}` }, - { - name: "description", - content: "Beheer de gewassen op je percelen.", - }, - ] -} - export async function loader(props: UpstreamRoute.LoaderArgs) { return originalLoader(props) } From a7b8363946681bba4ccd8e0da3e87932fa8d2c00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Wed, 25 Feb 2026 16:47:17 +0100 Subject: [PATCH 18/35] Oops --- .../app/lib/fertilizer-application-helpers.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 fdm-app/app/lib/fertilizer-application-helpers.ts diff --git a/fdm-app/app/lib/fertilizer-application-helpers.ts b/fdm-app/app/lib/fertilizer-application-helpers.ts new file mode 100644 index 000000000..d621d5e23 --- /dev/null +++ b/fdm-app/app/lib/fertilizer-application-helpers.ts @@ -0,0 +1,22 @@ +/** + * Takes a comma separated list of b_id:p_app_id pairs and converts it to a list of objects + * + * For example, `"a:b,c:d"` would parse into `[{b_id: "a", p_app_id: "b"}, {b_id: "c", p_app_id: "d"}]` + * + * Any string separated by `,` that is not a valid pair in the above format is discarded. + * Therefore, this function might return an empty array even if the string is not empty. + * + * + * @param value string to parse + * @returns array of objects + */ +export function parseAppIds(value: string) { + return value + .split(",") + .map((pairStr) => pairStr.split(":")) + .filter( + (pair) => + pair.length === 2 && pair[0].length > 0 && pair[1].length > 0, + ) + .map(([b_id, p_app_id]) => ({ b_id, p_app_id })) +} From 3927d21b8de79c9f284f76ec9de603deec55b129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 26 Feb 2026 11:03:19 +0100 Subject: [PATCH 19/35] Group by similar applications and remove option to choose grouping --- .../fertilizer-applications/columns.tsx | 3 +- .../blocks/fertilizer-applications/table.tsx | 134 +++++++----------- 2 files changed, 52 insertions(+), 85 deletions(-) diff --git a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx index aa68d8f92..b0ae537d4 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx @@ -71,6 +71,7 @@ export const columns: ColumnDef[] = [ ), }, { + id: "applications.length", accessorKey: "applications.length", header: "Aantal Bemestingen", }, @@ -173,7 +174,7 @@ export const columns: ColumnDef[] = [ ) return ( - + application.b_id), + mapBySimilar: createMapper( + (application) => + `${application.p_app_id}#${application.p_app_date.getTime()}#${application.p_app_amount?.toPrecision(4)}`, + ), mapEach: createMapper((application) => application.p_app_id), } as const @@ -139,19 +142,9 @@ export function DataTable({ fertilizerApplications: ApplicationExtended[][] returnUrl: string }) { - const [rowMapper, setRowMapper] = - useState("mapByDate") - - const numFertilizerApplications = fertilizerApplications - .map((apps) => apps.length) - .reduce((a, b) => a + b, 0) - - const shouldShowGroupingSelector = - numFields > 1 && numFertilizerApplications > 1 - const records = useMemo( - () => groupAndOrderFertApps(fertilizerApplications, rowMapper), - [fertilizerApplications, rowMapper], + () => groupAndOrderFertApps(fertilizerApplications, "mapBySimilar"), + [fertilizerApplications], ) const columnVisibility = useMemo( @@ -172,84 +165,57 @@ export function DataTable({ data: records, getCoreRowModel: getCoreRowModel(), meta: { returnUrl }, - state: { + initialState: { columnVisibility: columnVisibility, }, }) return ( - <> - {shouldShowGroupingSelector && ( -
-

- Groeperen op{" "} -

- { - if (value in mappers) - setRowMapper(value as keyof typeof mappers) - }} - > - - Datum - - Volgorde - - - Niet groeperen - - - -
- )} - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef - .header, - header.getContext(), - )} - - ) - })} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - ))} - -
- + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ) + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ))} + + ) } From b014f4272ed9ac43b6d45d19cb30517f45e3ffd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 26 Feb 2026 12:42:09 +0100 Subject: [PATCH 20/35] Add conditional rendering of the fertilizer application modification buttons --- .../fertilizer-applications/columns.tsx | 108 ++++++++++-------- 1 file changed, 59 insertions(+), 49 deletions(-) diff --git a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx index b0ae537d4..10a3d350f 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx @@ -1,5 +1,5 @@ import type { FertilizerApplication } from "@nmi-agro/fdm-core" -import type { ColumnDef } from "@tanstack/react-table" +import type { CellContext, ColumnDef } from "@tanstack/react-table" import { format } from "date-fns" import { nl } from "date-fns/locale" import { useMemo } from "react" @@ -158,53 +158,63 @@ export const columns: ColumnDef[] = [ { id: "modify", header: "", - cell: ({ row, table }) => { - const params = useParams() - const fetcher = useFetcher() - const returnUrl = (table.options.meta as { returnUrl: string }) - .returnUrl - - const modifiableAppIds = useMemo( - () => - row.original.applications - .filter((app) => app.canModify) - .map((app) => `${app.b_id}:${app.p_app_id}`) - .join(","), - [row.original], - ) - - return ( - - - - - - - - - ) - }, + cell: (ctx) => + ctx.row.original.applications.some((app) => app.canModify) && ( + + ), }, ] + +/** + * Renders two buttons that let the user edit or remove the applications found in the row record. + * + * The edit button will navigate to the add/update fertilizer page with the necessary search params. + * + * The delete button will delete the fertilizer and cause the row to disappear. + * + * @param param0 all of the React Table cell context + * @returns a React node that can be set as the cell contents + */ +function ModifyCell({ row, table }: CellContext) { + const params = useParams() + const fetcher = useFetcher() + const returnUrl = (table.options.meta as { returnUrl: string }).returnUrl + + const modifiableAppIds = useMemo( + () => + row.original.applications + .filter((app) => app.canModify) + .map((app) => `${app.b_id}:${app.p_app_id}`) + .join(","), + [row.original], + ) + + return ( + + + + + + + + + ) +} From 57c3dfca705ef5e716e07f787f21df4ebc270e6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 26 Feb 2026 12:50:41 +0100 Subject: [PATCH 21/35] Add documentation --- ....$calendar.rotation_.fertilizer._index.tsx | 79 ++++++++++++++++--- 1 file changed, 70 insertions(+), 9 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 8a8210d8d..e953757a3 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 @@ -97,11 +97,17 @@ export const meta: MetaFunction = () => { type PathParams = Route.LoaderArgs["params"] interface StrategizedLoaderData { + /** List of fields that can be selected by the user */ fields: (Field & { cultivations?: string[] })[] + /** Which fields are selected */ selectedFieldIds: string[] + /** If the user can actually change their selection */ canReselect: boolean + /** List of application ids to modify, if this is a modification */ appIds: string[] | null + /** Fertilizer application to be shown as input placeholders */ exampleFertilizerApplication?: Partial | null + /** Fertilizer application to use to pre-fill the form */ fertilizerApplication?: Partial | null } function loadByStrategy( @@ -119,7 +125,7 @@ function loadByStrategy( statusText: "invalid: appIds", }) } - return loadByAppIds(principal_id, params, appIdPairs) + return loadByAppIds(principal_id, appIdPairs) } // Get cultivationIds from search params @@ -145,6 +151,21 @@ function loadByStrategy( ) } +/** + * Loads the data necessary to render the add new fertilizer application form. + * + * By default, all the fields will be selected to add an application, but the initial selection + * state can be controlled via the fieldIds search param. + * + * Since the appIds as null, the interface should indicate that new fertilizer applications + * are being added. + * + * @param principal_id + * @param params + * @param cultivationIds + * @param fieldIds + * @returns + */ async function loadByCultivationAndFieldIds( principal_id: string, params: PathParams, @@ -198,9 +219,25 @@ async function loadByCultivationAndFieldIds( } } +/** + * Loads the data for the case where a set of existing fertilizer applications are being modified. + * + * appIds will contain the application ids to be submitted as part of the form. + * + * fertilizerApplication will include the values equal between the applications, or at least similar, + * for numerical data types. + * exampleFertilizerApplication application will be the first application found. + * + * Cultivation IDs will be missing. The page should not show any errors about this. + * As can be seen from the canReselect value, the user logically can't deselect fields even if they + * don't have the cultivation. + * + * @param principal_id + * @param applicationRefs + * @returns + */ async function loadByAppIds( principal_id: string, - _params: PathParams, applicationRefs: ReturnType, ): Promise { const fieldIds = [...new Set(applicationRefs.map((ref) => ref.b_id))] @@ -247,6 +284,7 @@ async function loadByAppIds( // Only keep values that are common between the fertilizer applications for (const key of keys) { for (const app of fertilizerApplications) { + // If the value is missing for this application, clear the value on the initial form data if ( exampleFertilizerApplication[key] === null || typeof exampleFertilizerApplication[key] === "undefined" || @@ -256,13 +294,25 @@ async function loadByAppIds( delete fertilizerApplication[key] continue } - if ( - keyTypes[key] === "date" - ? ( - exampleFertilizerApplication[key] as Date - ).getTime() !== (app[key] as Date).getTime() - : exampleFertilizerApplication[key] !== app[key] + + if (keyTypes[key] === "number") { + // If the value is too different from the initial app, consider them to be different + // The outcome of this actually depends on the ordering of the applications, but it is fine + // The previous route is supposed to sort the applications, so the outcome should be stable at least + const a = app[key] as number + const b = exampleFertilizerApplication[key] as number + if (a !== b && Math.abs(a - b) > Math.abs(b) / 100) { + delete fertilizerApplication[key] + } + } else if ( + keyTypes[key] === "date" && + (exampleFertilizerApplication[key] as Date).getTime() !== + (app[key] as Date).getTime() ) { + // If it is not the same date they are different + delete fertilizerApplication[key] + } else if (exampleFertilizerApplication[key] !== app[key]) { + // Any other type of value is compared using !== delete fertilizerApplication[key] } } @@ -286,6 +336,17 @@ async function loadByAppIds( } } +/** + * Collects all the data necessary to render the page + * + * Part of the data is loaded using different strategies. Strategy to be used is determined according to + * the search parameters by `loadByStrategy` which in turn calls `loadByCultivationAndFieldIds` or `loadByAppIds` + * + * @param param0 route loader arguments + * @returns `fertiizerApplication.p_app_ids` will be defined if this is a fertilizer application modification. + * `canReselect` will be set to true if the user should be able to change their field selection. + * @throws `handleLoaderError` applied to any error that can occur while loading + */ export async function loader({ request, params }: Route.LoaderArgs) { try { // Get the session @@ -543,7 +604,7 @@ export default function FarmRotationFertilizerAddIndex() { De bemesting{" "} {isModification - ? "is toegepast" + ? "wordt gewijzigd" : "wordt toegepast"}{" "} op de volgende percelen. From 6a083a02ce6f6c5dbce12c9ad93b75aa4b8ae9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 26 Feb 2026 13:35:05 +0100 Subject: [PATCH 22/35] Use field fertilizer add page since it is more functionally similar --- .../fertilizer-applications/columns.tsx | 2 +- ...farm.$calendar.field.fertilizer._index.tsx | 229 +++++- ....$calendar.rotation_.fertilizer._index.tsx | 690 ++++++------------ 3 files changed, 415 insertions(+), 506 deletions(-) diff --git a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx index 10a3d350f..89dcb64ca 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx @@ -199,7 +199,7 @@ function ModifyCell({ row, table }: CellContext) { /> - + {canReselect && ( + + + + )} @@ -407,6 +546,12 @@ export default function FarmFieldFertilizerAddIndex() { searchParams.get("fieldIds") || "" } + fertilizerApplication={ + loaderData.fertilizerApplication + } + exampleFertilizerApplication={ + loaderData.exampleFertilizerApplication + } /> ) : (
@@ -438,6 +583,54 @@ export async function action({ request, params }: ActionFunctionArgs) { const fieldIds = url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? [] + const returnUrlParam = url.searchParams.get("returnUrl") + const returnUrl = + returnUrlParam && isOfOrigin(returnUrlParam, url.origin) + ? returnUrlParam + : url.searchParams.has("create") + ? `/farm/create/${b_id_farm}/${calendar}/rotation` + : `/farm/${b_id_farm}/${calendar}/rotation` + + if (url.searchParams.has("appIds")) { + const validatedData = await extractFormValuesFromRequest( + request, + FormSchemaPartialModify, + ) + + const p_app_ids = validatedData.p_app_id.split(",").filter(Boolean) + await fdm.transaction((tx) => + Promise.all( + p_app_ids.map(async (p_app_id) => { + const original = await getFertilizerApplication( + tx, + session.principal_id, + p_app_id, + ) + + if (!original) { + throw new Error( + `Application ${p_app_id} not found.`, + ) + } + + return updateFertilizerApplication( + tx, + session.principal_id, + p_app_id, + validatedData.p_id ?? original.p_id, + validatedData.p_app_amount ?? original.p_app_amount, + validatedData.p_app_method ?? original.p_app_method, + validatedData.p_app_date ?? original.p_app_date, + ) + }), + ), + ) + + return redirectWithSuccess(returnUrl, { + message: `${p_app_ids.length} ${p_app_ids.length === 1 ? "bemesting is" : "bemestingen zijn"} succesvol bijgewerkt.`, + }) + } + if (!fieldIds || fieldIds.length === 0) { return dataWithError(null, "Selecteer eerst een perceel.") } @@ -459,7 +652,7 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } - return redirectWithSuccess(`/farm/${b_id_farm}/${calendar}/field`, { + return redirectWithSuccess(returnUrl, { message: `Bemesting succesvol toegevoegd aan ${fieldIds.length} ${fieldIds.length === 1 ? "perceel" : "percelen"}.`, }) } catch (error) { 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 e953757a3..8acd9ec52 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 @@ -1,22 +1,18 @@ import { addFertilizerApplication, - type FertilizerApplication, - type Field, getCultivations, getCultivationsFromCatalogue, getFarms, - getFertilizerApplication, getFertilizerParametersDescription, getFertilizers, - getField, getFields, - updateFertilizerApplication, } from "@nmi-agro/fdm-core" import { AlertTriangle, Info } from "lucide-react" import { useEffect, useState } from "react" import { type ActionFunctionArgs, data, + type LoaderFunctionArgs, type MetaFunction, NavLink, redirect, @@ -33,10 +29,7 @@ import { FertilizerApplicationForm, type FertilizerOption, } from "~/components/blocks/fertilizer-applications/form" -import { - FormSchema, - FormSchemaPartialModify, -} from "~/components/blocks/fertilizer-applications/formschema" +import { FormSchema } from "~/components/blocks/fertilizer-applications/formschema" import { Header } from "~/components/blocks/header/base" import { HeaderFarm } from "~/components/blocks/header/farm" import { @@ -80,10 +73,8 @@ import { getCalendar, getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" -import { parseAppIds } from "~/lib/fertilizer-application-helpers" import { extractFormValuesFromRequest } from "~/lib/form" -import { isOfOrigin, modifySearchParams } from "~/lib/url-utils" -import type { Route } from "./+types/farm.$b_id_farm.$calendar.rotation_.fertilizer._index" +import { modifySearchParams } from "~/lib/url-utils" export const meta: MetaFunction = () => { return [ @@ -95,266 +86,30 @@ export const meta: MetaFunction = () => { ] } -type PathParams = Route.LoaderArgs["params"] -interface StrategizedLoaderData { - /** List of fields that can be selected by the user */ - fields: (Field & { cultivations?: string[] })[] - /** Which fields are selected */ - selectedFieldIds: string[] - /** If the user can actually change their selection */ - canReselect: boolean - /** List of application ids to modify, if this is a modification */ - appIds: string[] | null - /** Fertilizer application to be shown as input placeholders */ - exampleFertilizerApplication?: Partial | null - /** Fertilizer application to use to pre-fill the form */ - fertilizerApplication?: Partial | null -} -function loadByStrategy( - principal_id: string, - params: PathParams, - searchParams: URLSearchParams, -) { - if (searchParams.has("appIds")) { - // Get appIds from search params - const appIds = searchParams.get("appIds") ?? "" - const appIdPairs = parseAppIds(appIds) - if (appIdPairs.length === 0) { - throw data("invalid: appIds", { +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: "invalid: appIds", + statusText: "missing: b_id_farm", }) } - return loadByAppIds(principal_id, appIdPairs) - } - - // Get cultivationIds from search params - const cultivationIds = - searchParams.get("cultivationIds")?.split(",").filter(Boolean) ?? [] - - if (!cultivationIds || cultivationIds.length === 0) { - throw data("missing: cultivationIds", { - status: 400, - statusText: "missing: cultivationIds", - }) - } - - // Get fieldIds from search params (if any) - const fieldIdsFromSearchParams = - searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? null - - return loadByCultivationAndFieldIds( - principal_id, - params, - cultivationIds, - fieldIdsFromSearchParams, - ) -} - -/** - * Loads the data necessary to render the add new fertilizer application form. - * - * By default, all the fields will be selected to add an application, but the initial selection - * state can be controlled via the fieldIds search param. - * - * Since the appIds as null, the interface should indicate that new fertilizer applications - * are being added. - * - * @param principal_id - * @param params - * @param cultivationIds - * @param fieldIds - * @returns - */ -async function loadByCultivationAndFieldIds( - principal_id: string, - params: PathParams, - cultivationIds: string[], - fieldIds: string[] | null, -): Promise { - const timeframe = getTimeframe(params) - const fields = await getFields( - fdm, - principal_id, - params.b_id_farm, - timeframe, - ) - - const fieldsExtended = await Promise.all( - fields.map(async (field) => { - const cultivations = await getCultivations( - fdm, - principal_id, - field.b_id, - timeframe, - ) - return { - ...field, - cultivations: cultivations.map((c) => c.b_lu_catalogue), - } - }), - ) - - const fieldsWithCultivation = fieldsExtended.filter((field) => - field.cultivations.some((b_lu_catalogue) => - cultivationIds.includes(b_lu_catalogue), - ), - ) - const fieldIdsSet = new Set(fieldIds ?? []) - - const selectedFieldIds = fieldIds - ? fieldsWithCultivation - .map((cultivation) => cultivation.b_id) - .filter((b_id) => fieldIdsSet.has(b_id)) - : fieldsWithCultivation.map((cultivation) => cultivation.b_id) - - return { - fields: fieldsWithCultivation, - selectedFieldIds: selectedFieldIds, - canReselect: true, - appIds: null, - exampleFertilizerApplication: undefined, - fertilizerApplication: undefined, - } -} - -/** - * Loads the data for the case where a set of existing fertilizer applications are being modified. - * - * appIds will contain the application ids to be submitted as part of the form. - * - * fertilizerApplication will include the values equal between the applications, or at least similar, - * for numerical data types. - * exampleFertilizerApplication application will be the first application found. - * - * Cultivation IDs will be missing. The page should not show any errors about this. - * As can be seen from the canReselect value, the user logically can't deselect fields even if they - * don't have the cultivation. - * - * @param principal_id - * @param applicationRefs - * @returns - */ -async function loadByAppIds( - principal_id: string, - applicationRefs: ReturnType, -): Promise { - const fieldIds = [...new Set(applicationRefs.map((ref) => ref.b_id))] - - const fields = await Promise.all( - fieldIds.map((b_id) => getField(fdm, principal_id, b_id)), - ) - - const fertilizerApplications = await Promise.all( - applicationRefs.map(async ({ p_app_id }) => { - const application = await getFertilizerApplication( - fdm, - principal_id, - p_app_id, - ) - if (!application) { - throw new Error(`Application ${p_app_id} not found`) - } - return application - }), - ) - - let exampleFertilizerApplication: Partial = {} - let fertilizerApplication: Partial = {} - - if (fertilizerApplications.length > 0) { - // These will be shown as form placeholders at worst - exampleFertilizerApplication = { ...fertilizerApplications[0] } - - // These keys are shown on the form - const keyTypes = { - p_id: "string", - p_app_date: "date", - p_app_method: "string", - p_app_amount: "number", - } as const - const keys = Object.keys(keyTypes) as (keyof typeof keyTypes)[] - - // Select the values that can be shown on the form - fertilizerApplication = Object.fromEntries( - keys.map((key) => [key, exampleFertilizerApplication[key]]), - ) - - // Only keep values that are common between the fertilizer applications - for (const key of keys) { - for (const app of fertilizerApplications) { - // If the value is missing for this application, clear the value on the initial form data - if ( - exampleFertilizerApplication[key] === null || - typeof exampleFertilizerApplication[key] === "undefined" || - app[key] === null || - typeof app[key] === "undefined" - ) { - delete fertilizerApplication[key] - continue - } - - if (keyTypes[key] === "number") { - // If the value is too different from the initial app, consider them to be different - // The outcome of this actually depends on the ordering of the applications, but it is fine - // The previous route is supposed to sort the applications, so the outcome should be stable at least - const a = app[key] as number - const b = exampleFertilizerApplication[key] as number - if (a !== b && Math.abs(a - b) > Math.abs(b) / 100) { - delete fertilizerApplication[key] - } - } else if ( - keyTypes[key] === "date" && - (exampleFertilizerApplication[key] as Date).getTime() !== - (app[key] as Date).getTime() - ) { - // If it is not the same date they are different - delete fertilizerApplication[key] - } else if (exampleFertilizerApplication[key] !== app[key]) { - // Any other type of value is compared using !== - delete fertilizerApplication[key] - } - } - } - - // If the fertilizer types are different, assume the application methods are different too - if (!fertilizerApplication.p_id) { - delete fertilizerApplication.p_app_method - // Also, no specific placeholder should be shown - delete exampleFertilizerApplication.p_app_amount - } - } - - return { - fields: fields as StrategizedLoaderData["fields"], - selectedFieldIds: fields.map((field) => field.b_id), - canReselect: false, - appIds: fertilizerApplications.map((app) => app.p_app_id), - exampleFertilizerApplication: exampleFertilizerApplication, - fertilizerApplication: fertilizerApplication, - } -} + // Get cultivationIds from search params + const url = new URL(request.url) + const cultivationIds = + url.searchParams + .get("cultivationIds") + ?.split(",") + .filter(Boolean) ?? [] -/** - * Collects all the data necessary to render the page - * - * Part of the data is loaded using different strategies. Strategy to be used is determined according to - * the search parameters by `loadByStrategy` which in turn calls `loadByCultivationAndFieldIds` or `loadByAppIds` - * - * @param param0 route loader arguments - * @returns `fertiizerApplication.p_app_ids` will be defined if this is a fertilizer application modification. - * `canReselect` will be set to true if the user should be able to change their field selection. - * @throws `handleLoaderError` applied to any error that can occur while loading - */ -export async function loader({ request, params }: Route.LoaderArgs) { - try { // Get the session const session = await getSession(request) - const url = new URL(request.url) - // Get timeframe from calendar store + const timeframe = getTimeframe(params) const calendar = getCalendar(params) // Get a list of possible farms of the user @@ -376,24 +131,79 @@ export async function loader({ request, params }: Route.LoaderArgs) { } }) - const { - fields, - selectedFieldIds, - canReselect, - appIds, - exampleFertilizerApplication, - fertilizerApplication, - } = await loadByStrategy(session.principal_id, params, url.searchParams) + // 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 = [] - const fieldOptions = fields.map((field) => { + 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 ?? 0) * 10) / 10, - cultivations: field.cultivations ?? [], // Pass cultivations for each field + b_area: Math.round(field.b_area * 10) / 10, + cultivations: field.cultivations, // Pass cultivations for each field } }) @@ -401,7 +211,7 @@ export async function loader({ request, params }: Route.LoaderArgs) { const fertilizers = await getFertilizers( fdm, session.principal_id, - params.b_id_farm, + b_id_farm, ) const fertilizerParameterDescription = getFertilizerParametersDescription() @@ -409,77 +219,69 @@ export async function loader({ request, params }: Route.LoaderArgs) { (x: { parameter: string }) => x.parameter === "p_app_method_options", ) - const applicationMethodOptionsRaw = applicationMethods?.options - if (!applicationMethodOptionsRaw) - throw new Error("Parameter metadata missing") + 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 = applicationMethodOptionsRaw.find( - (x) => x.value === opt, + .map((opt: string) => { + const meta = applicationMethods.options.find( + (x: { value: string }) => x.value === opt, ) return meta ? { value: opt, label: meta.label } : undefined }) .filter( - (option): option is { value: string; label: string } => + (option: { + value: string + label: string + }): option is { value: string; label: string } => option !== undefined, ) return { value: fertilizer.p_id, - label: fertilizer.p_name_nl ?? "Onbekend", + label: fertilizer.p_name_nl, applicationMethodOptions: applicationMethodOptions, } }, ) - const catalogueCultivations = await getCultivationsFromCatalogue( - fdm, - session.principal_id, - params.b_id_farm, - ) - const cultivationIds = - url.searchParams - .get("cultivationIds") - ?.split(",") - .filter(Boolean) ?? [] - const cultivationNames = cultivationIds - .map( - (b_lu_catalogue) => - catalogueCultivations.find( - (c) => c.b_lu_catalogue === b_lu_catalogue, - )?.b_lu_name, - ) - .filter((v) => v) - .sort() - // Return user information from loader return { - b_id_farm: params.b_id_farm, + b_id_farm: b_id_farm, farmOptions: farmOptions, - fieldAmount: selectedFieldIds.length, + fieldAmount: selectedFields.length, fertilizerOptions: fertilizerOptions, calendar: calendar, - selectedFieldIds: selectedFieldIds, - fieldOptions: fieldOptions.map((field) => ({ - b_id: field.b_id, - b_name: field.b_name, - b_area: field.b_area, - cultivations: field.cultivations, - })), // All fields for selection - cultivationNames: cultivationNames, + 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, - canReselect: canReselect, - exampleFertilizerApplication: exampleFertilizerApplication, - fertilizerApplication: fertilizerApplication - ? { - ...fertilizerApplication, - p_app_ids: appIds, - } - : undefined, create: url.searchParams.has("create"), } } catch (error) { @@ -494,12 +296,14 @@ export default function FarmRotationFertilizerAddIndex() { const [searchParams, setSearchParams] = useSearchParams() const [open, setOpen] = useState(false) const [selectedFieldIds, setSelectedFieldIds] = useState( - loaderData.selectedFieldIds, + loaderData.selectedFields.map((field) => field.b_id!), ) useEffect(() => { - setSelectedFieldIds(loaderData.selectedFieldIds) - }, [loaderData.selectedFieldIds]) + setSelectedFieldIds( + loaderData.selectedFields.map((field) => field.b_id!), + ) + }, [loaderData.selectedFields]) const isSubmitting = navigation.state === "submitting" @@ -525,7 +329,7 @@ export default function FarmRotationFertilizerAddIndex() { } const displayedSelectedFields = loaderData.fieldOptions.filter((field) => - selectedFieldIds.includes(field.b_id), + selectedFieldIds.includes(field.b_id!), ) function handleSelectionDialogOpenChange(open: boolean) { @@ -535,10 +339,6 @@ export default function FarmRotationFertilizerAddIndex() { setOpen(open) } - const isModification = !!loaderData.fertilizerApplication?.p_app_ids - const returnUrl = searchParams.get("returnUrl") - const comingFromFieldsTable = returnUrl?.includes("/field/") - return (
- {comingFromFieldsTable ? "Percelen" : "Bouwplan"} + Bouwplan - {isModification - ? "Bemesting wijzigen" - : "Bemesting toevoegen"} + Bemesting toevoegen
{isSubmitting && (
- - Bemesting wordt{" "} - {isModification - ? "bijgewerkt..." - : "toegevoegd..."} - + Bemesting wordt toegevoegd...
)} @@ -602,11 +385,8 @@ export default function FarmRotationFertilizerAddIndex() { Geselecteerde percelen - De bemesting{" "} - {isModification - ? "wordt gewijzigd" - : "wordt toegepast"}{" "} - op de volgende percelen. + De bemesting wordt toegepast op de + volgende percelen. @@ -622,34 +402,33 @@ export default function FarmRotationFertilizerAddIndex() { {field.b_name}

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

- Dit - perceel - heeft - het - geselecteerde - gewas - niet -

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

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

+
+
+
+ )} {field.b_area}{" "} ha @@ -691,16 +470,14 @@ export default function FarmRotationFertilizerAddIndex() { handleSelectionDialogOpenChange } > - {loaderData.canReselect && ( - - - - )} + + + @@ -770,13 +547,9 @@ export default function FarmRotationFertilizerAddIndex() { Percelen zonder{" "} - {loaderData - .cultivationNames - .length === - 1 - ? loaderData - .cultivationNames[0] - : "deze gewassen"} + { + loaderData.cultivationName + } @@ -865,13 +638,9 @@ export default function FarmRotationFertilizerAddIndex() { {loaderData.fieldAmount === 0 ? "Selecteer eerst een of meerdere percelen." - : isModification - ? loaderData.fieldAmount === 1 - ? "Werk de bemesting van het geselecteerde perceel bij." - : `Werk de bemesting van de ${loaderData.fieldAmount} geselecteerde percelen bij.` - : 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 === 1 + ? "Voeg een nieuwe bemestingstoepassing toe aan het geselecteerde perceel." + : `Voeg een nieuwe bemestingstoepassing toe aan de ${loaderData.fieldAmount} geselecteerde percelen.`} @@ -897,17 +666,7 @@ export default function FarmRotationFertilizerAddIndex() { "cultivationIds", ) || "cultivationIds" } - fertilizerApplication={ - loaderData.fertilizerApplication - } - exampleFertilizerApplication={ - loaderData.exampleFertilizerApplication - } - schema={ - loaderData.exampleFertilizerApplication - ? FormSchemaPartialModify - : FormSchema - } + fertilizerApplication={undefined} /> ) : (
@@ -930,56 +689,17 @@ export default function FarmRotationFertilizerAddIndex() { 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 returnUrlParam = url.searchParams.get("returnUrl") - const returnUrl = - returnUrlParam && isOfOrigin(returnUrlParam, url.origin) - ? returnUrlParam - : url.searchParams.has("create") - ? `/farm/create/${b_id_farm}/${calendar}/rotation` - : `/farm/${b_id_farm}/${calendar}/rotation` - - if (url.searchParams.has("appIds")) { - const validatedData = await extractFormValuesFromRequest( - request, - FormSchemaPartialModify, - ) - - const p_app_ids = validatedData.p_app_id.split(",").filter(Boolean) - await fdm.transaction((tx) => - Promise.all( - p_app_ids.map(async (p_app_id) => { - const original = await getFertilizerApplication( - tx, - session.principal_id, - p_app_id, - ) - - if (!original) { - throw new Error( - `Application ${p_app_id} not found.`, - ) - } - - return updateFertilizerApplication( - tx, - session.principal_id, - p_app_id, - validatedData.p_id ?? original.p_id, - validatedData.p_app_amount ?? original.p_app_amount, - validatedData.p_app_method ?? original.p_app_method, - validatedData.p_app_date ?? original.p_app_date, - ) - }), - ), - ) - - return redirectWithSuccess(returnUrl, { - message: `${p_app_ids.length} ${p_app_ids.length === 1 ? "bemesting is" : "bemestingen zijn"} succesvol bijgewerkt.`, - }) + if (!fieldIds || fieldIds.length === 0) { + return dataWithError(null, "Selecteer eerst een perceel.") } const validatedData = await extractFormValuesFromRequest( @@ -987,32 +707,28 @@ export async function action({ request, params }: ActionFunctionArgs) { FormSchema, ) - const fieldIds = - url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? [] - - if (!fieldIds || fieldIds.length === 0) { - return dataWithError(null, "Selecteer eerst een perceel.") - } - - await fdm.transaction((tx) => - Promise.all( - fieldIds.map((fieldId) => - addFertilizerApplication( - tx, - 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(returnUrl, { - message: `Bemesting succesvol toegevoegd aan ${fieldIds.length} ${fieldIds.length === 1 ? "perceel" : "percelen"}.`, - }) + return redirectWithSuccess( + url.searchParams.has("create") + ? `/farm/create/${b_id_farm}/${calendar}/rotation` + : `/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( From 6119a3e3e66fc7b3fc7c206c4d7a579c68483874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 26 Feb 2026 14:02:44 +0100 Subject: [PATCH 23/35] Nitpicks in column.tsx --- .../app/components/blocks/fertilizer-applications/columns.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx index 89dcb64ca..4227de557 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx @@ -48,7 +48,7 @@ function formatDateRange(dates: Date[]) { * Stringifies a single number with an unit if the given range is all the same numbers or very close down to roughly 2 decimal places, * otherwise stringifies the least and the greatest numbers with a dash in between and the unit at the end. * - * @param dates array of numbers. Nulls and undefined items are not allowed. + * @param numbers array of numbers. Nulls and undefined items are not allowed. * @returns the formatted string. */ function formatNumberRange(numbers: number[], unit = "") { @@ -121,7 +121,7 @@ export const columns: ColumnDef[] = [ ) : ( - fieldNames[0][1] + (fieldNames[0]?.[1] ?? "") ) }, }, From 8c1b1247736c34915bd4c940d8346b42e88e6cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 26 Feb 2026 14:12:03 +0100 Subject: [PATCH 24/35] Nitpicks on the field fertilizer add page --- ...farm.$calendar.field.fertilizer._index.tsx | 67 ++++++++++++------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer._index.tsx index e8bf761f8..9603a107a 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer._index.tsx @@ -341,8 +341,6 @@ export default function FarmFieldFertilizerAddIndex() { const isModification = !!loaderData.fertilizerApplication?.p_app_ids const canReselect = !isModification - const title = isModification ? "Bemesting wijzigen" : "Bemesting toevoegen" - return (
- {} + + {isModification + ? "Bemesting wijzigen" + : "Bemesting toevoegen"} +
- De bemesting wordt toegepast op de - volgende percelen. + {`De bemesting wordt ${isModification ? "gewijzigd" : "toegepast"} op de volgende percelen.`} @@ -524,13 +529,21 @@ export default function FarmFieldFertilizerAddIndex() { - Bemesting toevoegen + + {isModification + ? "Bemesting wijzigen" + : "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.`} + : isModification + ? loaderData.fieldAmount === 1 + ? "Wijzig de bemesting van het geselecteerde perceel." + : `Wijzig de bemesting van de ${loaderData.fieldAmount} geselecteerde percelen.` + : loaderData.fieldAmount === 1 + ? "Voeg een nieuwe bemestingstoepassing toe aan het geselecteerde perceel." + : `Voeg een nieuwe bemestingstoepassing toe aan de ${loaderData.fieldAmount} geselecteerde percelen.`} @@ -580,8 +593,6 @@ export async function action({ request, params }: ActionFunctionArgs) { const session = await getSession(request) const url = new URL(request.url) - const fieldIds = - url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? [] const returnUrlParam = url.searchParams.get("returnUrl") const returnUrl = @@ -631,6 +642,12 @@ export async function action({ request, params }: ActionFunctionArgs) { }) } + const fieldIds = [ + ...new Set( + url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? + [], + ), + ] if (!fieldIds || fieldIds.length === 0) { return dataWithError(null, "Selecteer eerst een perceel.") } @@ -640,17 +657,21 @@ 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, - ) - } + fdm.transaction((tx) => + Promise.all( + fieldIds.map((b_id) => + addFertilizerApplication( + tx, + session.principal_id, + b_id, + validatedData.p_id, + validatedData.p_app_amount, + validatedData.p_app_method, + validatedData.p_app_date, + ), + ), + ), + ) return redirectWithSuccess(returnUrl, { message: `Bemesting succesvol toegevoegd aan ${fieldIds.length} ${fieldIds.length === 1 ? "perceel" : "percelen"}.`, From f6f8b22125ce42c9f41ee2add819717beffcb968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 26 Feb 2026 14:13:18 +0100 Subject: [PATCH 25/35] Harden isOfOrigin --- fdm-app/app/lib/url-utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fdm-app/app/lib/url-utils.ts b/fdm-app/app/lib/url-utils.ts index 150dc036b..c8e830bd7 100644 --- a/fdm-app/app/lib/url-utils.ts +++ b/fdm-app/app/lib/url-utils.ts @@ -64,7 +64,8 @@ export function getSearchParams(href: string) { */ export function isOfOrigin(href: string, origin: string) { try { - return !href.includes("://") || new URL(href).origin === origin + if (href.startsWith("/") && !href.startsWith("//")) return true + return !href.includes("//") || new URL(href).origin === origin } catch { return false } From 16c7cdbcb5b3c2941612da20bffa60b6f462c866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 26 Feb 2026 14:13:53 +0100 Subject: [PATCH 26/35] Remove memoization from table cells --- .../fertilizer-applications/columns.tsx | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx index 4227de557..92bcdfeed 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx @@ -2,7 +2,6 @@ import type { FertilizerApplication } from "@nmi-agro/fdm-core" import type { CellContext, ColumnDef } from "@tanstack/react-table" import { format } from "date-fns" import { nl } from "date-fns/locale" -import { useMemo } from "react" import { NavLink, useFetcher, useParams } from "react-router" import { cn } from "@/app/lib/utils" import { Button } from "~/components/ui/button" @@ -79,18 +78,14 @@ export const columns: ColumnDef[] = [ id: "b_name", header: "Perceel", cell: ({ row }) => { - const fieldNames = useMemo( - () => - Object.entries( - Object.fromEntries( - row.original.applications.map((app) => [ - app.b_id, - app.b_name, - ]), - ), - ).sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)), - [row.original], - ) + const fieldNames = Object.entries( + Object.fromEntries( + row.original.applications.map((app) => [ + app.b_id, + app.b_name, + ]), + ), + ).sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)) return row.original.applications.length > 1 ? ( @@ -180,14 +175,10 @@ function ModifyCell({ row, table }: CellContext) { const fetcher = useFetcher() const returnUrl = (table.options.meta as { returnUrl: string }).returnUrl - const modifiableAppIds = useMemo( - () => - row.original.applications - .filter((app) => app.canModify) - .map((app) => `${app.b_id}:${app.p_app_id}`) - .join(","), - [row.original], - ) + const modifiableAppIds = row.original.applications + .filter((app) => app.canModify) + .map((app) => `${app.b_id}:${app.p_app_id}`) + .join(",") return ( From 31401ef24a172e6c841ca5ee4365462e82145daa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 26 Feb 2026 14:15:13 +0100 Subject: [PATCH 27/35] Import format --- .../farm.$b_id_farm.$calendar.field.fertilizer._index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer._index.tsx index 9603a107a..419b3fc2d 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer._index.tsx @@ -62,9 +62,9 @@ import { getCalendar, getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" +import { parseAppIds } from "~/lib/fertilizer-application-helpers" import { extractFormValuesFromRequest } from "~/lib/form" -import { parseAppIds } from "../lib/fertilizer-application-helpers" -import { isOfOrigin } from "../lib/url-utils" +import { isOfOrigin } from "~/lib/url-utils" export const meta: MetaFunction = () => { return [ From 3d4cedc4b742c03e9dffd126fd906b0be1655efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 26 Feb 2026 15:26:04 +0100 Subject: [PATCH 28/35] Change some logic --- .../blocks/fertilizer-applications/columns.tsx | 14 +++++++++++--- .../blocks/fertilizer-applications/table.tsx | 14 ++++++++++---- ...b_id_farm.$calendar.field.fertilizer._index.tsx | 2 +- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx index 92bcdfeed..6dc6b3abd 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx @@ -1,6 +1,6 @@ import type { FertilizerApplication } from "@nmi-agro/fdm-core" import type { CellContext, ColumnDef } from "@tanstack/react-table" -import { format } from "date-fns" +import { endOfDay, format } from "date-fns" import { nl } from "date-fns/locale" import { NavLink, useFetcher, useParams } from "react-router" import { cn } from "@/app/lib/utils" @@ -38,7 +38,7 @@ function formatDateRange(dates: Date[]) { if (dates.length === 0) return "" const firstDate = dates[0] const lastDate = dates[dates.length - 1] - return firstDate.getTime() === lastDate.getTime() + return createDateKey(firstDate) === createDateKey(lastDate) ? `${format(firstDate, "PP", { locale: nl })}` : `${format(firstDate, "PP", { locale: nl })} - ${format(lastDate, "PP", { locale: nl })}` } @@ -60,6 +60,14 @@ function formatNumberRange(numbers: number[], unit = "") { : `${firstNumber} - ${lastNumber} ${unit}` } +/** + * Returns a date string that doesn't contain the time. + * + * The app interface doesn't really show the time in the day for dates, so we ignore the time this way */ +export function createDateKey(date: Date) { + return format(endOfDay(date), "yyyy-MM-dd") +} + export const columns: ColumnDef[] = [ { id: "p_app_date", @@ -86,7 +94,7 @@ export const columns: ColumnDef[] = [ ]), ), ).sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)) - return row.original.applications.length > 1 ? ( + return fieldNames.length > 1 ? (