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. 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..ec8ac915d --- /dev/null +++ b/fdm-app/app/components/blocks/fertilizer-applications/columns.tsx @@ -0,0 +1,262 @@ +import type { FertilizerApplication } from "@nmi-agro/fdm-core" +import type { CellContext, ColumnDef } from "@tanstack/react-table" +import { endOfDay, format } from "date-fns" +import { nl } from "date-fns/locale" +import { ChevronDown } from "lucide-react" +import { useState } from "react" +import { NavLink, useFetcher, useParams } from "react-router" +import { cn } from "@/app/lib/utils" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/components/ui/alert-dialog" +import { Button } from "~/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" +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 createDateKey(firstDate) === createDateKey(lastDate) + ? `${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 numbers 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}` +} + +/** + * 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", + header: "Datum", + cell: ({ row }) => + formatDateRange( + row.original.applications.map((app) => app.p_app_date), + ), + }, + { + id: "applications.length", + accessorKey: "applications.length", + header: "Aantal bemestingen", + }, + { + id: "b_name", + header: "Perceel", + cell: ({ row }) => { + 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)) + if (fieldNames.length <= 1) { + return fieldNames[0]?.[1] ?? "" + } + return ( + + + + + + = 8 + ? "h-72 overflow-y-auto w-48" + : "w-48" + } + > +
+ {fieldNames.map(([b_id, b_name]) => ( + + {b_name} + + ))} +
+
+
+
+ ) + }, + }, + { + 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((methodName) => methodName !== 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: (ctx) => + ctx.row.original.applications.some((app) => app.canModify) && ( + + ), + }, +] + +/** + * Renders 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 show a confirmation dialog before deleting the fertilizer application(s). + * + * @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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + const modifiableApps = row.original.applications.filter( + (app) => app.canModify, + ) + const modifiableAppIds = modifiableApps + .map((app) => `${app.b_id}:${app.p_app_id}`) + .join(",") + + return ( +
+ + + + + + + + {modifiableApps.length === 1 + ? "Bemesting verwijderen?" + : `${modifiableApps.length} bemestingen verwijderen?`} + + + {modifiableApps.length === 1 + ? "Weet je zeker dat je deze bemesting wilt verwijderen? Dit kan niet ongedaan worden gemaakt." + : `Weet je zeker dat je deze ${modifiableApps.length} bemestingen wilt verwijderen? Dit kan niet ongedaan worden gemaakt.`} + + + + Annuleren + { + const formData = new FormData() + formData.set("appIds", modifiableAppIds) + formData.set("intent", "remove_application") + fetcher.submit(formData, { method: "POST" }) + }} + > + Verwijderen + + + + +
+ ) +} diff --git a/fdm-app/app/components/blocks/fertilizer-applications/dialog.tsx b/fdm-app/app/components/blocks/fertilizer-applications/dialog.tsx new file mode 100644 index 000000000..8075ca357 --- /dev/null +++ b/fdm-app/app/components/blocks/fertilizer-applications/dialog.tsx @@ -0,0 +1,106 @@ +import type { ApplicationExtended } from "~/components/blocks/fertilizer-applications/columns" +import { DataTable } from "~/components/blocks/fertilizer-applications/table" +import { Button } from "~/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog" +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "~/components/ui/empty" +import { ScrollArea } from "~/components/ui/scroll-area" + +interface FertilizerInfo { + p_id: string + p_name_nl: string | null +} + +interface FertilizerApplicationListDialogProps { + isForRotation: boolean + numFields: number + fertilizer: FertilizerInfo + fertilizerApplications: ApplicationExtended[][] + returnUrl: string + onClose: () => void +} + +export function FertilizerApplicationListDialog({ + isForRotation, + numFields, + fertilizer, + fertilizerApplications, + returnUrl, + onClose, +}: FertilizerApplicationListDialogProps) { + const numFertilizerApplications = fertilizerApplications + .map((apps) => apps.length) + .reduce((a, b) => a + b, 0) + + const fieldNameToShow = + numFields === 1 + ? fertilizerApplications.find((apps) => apps.length > 0)?.[0].b_name + : undefined + + const titleSuffix = fieldNameToShow + ? ` op ${fieldNameToShow}` + : numFields > 1 + ? ` op ${numFields} percelen` + : undefined + + return ( + + + + + {fertilizer.p_name_nl} + {titleSuffix} + + + Bekijk en beheer de bemestingen met deze meststof. + + + {numFertilizerApplications > 0 ? ( + + + + ) : ( + + + Geen bemestingen gevonden + + {isForRotation + ? "Deze meststof wordt niet langer op dit perceel/deze percelen en gewassen toegepast." + : numFields > 1 + ? "Deze meststof wordt niet langer op deze percelen toegepast." + : "Deze meststof wordt niet langer op dit perceel toegepast."} + + + Sluit dit venster om een nieuwe bemesting toe te + voegen. + + + + )} + + + + + + + + ) +} diff --git a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx index bc0a6c81a..78a10c96c 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 "@nmi-agro/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, @@ -36,6 +32,7 @@ import { type FieldFertilizerFormValues, FormSchema, FormSchemaModify, + type FormSchemaPartial, } from "./formschema" export type FertilizerOption = { @@ -44,20 +41,39 @@ export type FertilizerOption = { applicationMethodOptions?: { value: string; label: string }[] } -export function FertilizerApplicationForm({ +/** + * Renders a fertilizer application creation or modification form. + * + * The form contains fields for the fertilizer applied, the application method, the amount, and the application date. + * + * It accepts two partial FieldFertilizerFormValues objects to pre-fill the form and set the placeholders. + * - The fields are pre-filled according to the fertilizerApplication prop value. If it is missing, all form fields are left empty. fertilizerApplication cannot be used to drive the form state. + * - The placeholders will be set according to the exampleFertilizerApplication prop value. If it is missing, the default placeholders are used for all form fields. + */ +export function FertilizerApplicationForm({ options, action, navigation, b_id_farm, b_id_or_b_lu_catalogue, fertilizerApplication, + exampleFertilizerApplication, + schema, }: { options: FertilizerOption[] action: string navigation: Navigation b_id_farm: string b_id_or_b_lu_catalogue: string - fertilizerApplication: FertilizerApplication + fertilizerApplication?: + | Partial + | null + | undefined + exampleFertilizerApplication?: + | Partial + | null + | undefined + schema?: T }) { const navigate = useNavigate() const [searchParams] = useSearchParams() @@ -65,7 +81,7 @@ export function FertilizerApplicationForm({ const form = useRemixForm({ mode: "onTouched", resolver: zodResolver( - fertilizerApplication ? FormSchemaModify : FormSchema, + schema ?? (fertilizerApplication ? FormSchemaModify : FormSchema), ), defaultValues: { p_app_id: fertilizerApplication?.p_app_ids @@ -74,7 +90,11 @@ export function FertilizerApplicationForm({ p_id: fertilizerApplication?.p_id, p_app_method: fertilizerApplication?.p_app_method, p_app_amount: undefined, // Handled through an effect due to blank behavior - p_app_date: fertilizerApplication?.p_app_date ?? new Date(), + p_app_date: fertilizerApplication?.p_app_date + ? fertilizerApplication.p_app_date + : exampleFertilizerApplication + ? undefined + : new Date(), }, submitConfig: { method: fertilizerApplication ? "PUT" : "POST", @@ -120,10 +140,11 @@ export function FertilizerApplicationForm({ ]) useEffect(() => { - if (fertilizerApplication) { - 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") @@ -158,7 +179,7 @@ export function FertilizerApplicationForm({ } return ( - +
-
-
+
+
} + defaultValue={fertilizerApplication?.p_id} />
-
-

 

+
+
- ( - - Toedieningsmethode + render={({ field, fieldState }) => ( + + Toedieningsmethode - - - + {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 - /> - - - - - )} - /> -
-
- -
- + {canReselect && ( + + + + )} @@ -385,13 +534,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.`} @@ -407,6 +564,12 @@ export default function FarmFieldFertilizerAddIndex() { searchParams.get("fieldIds") || "" } + fertilizerApplication={ + loaderData.fertilizerApplication + } + exampleFertilizerApplication={ + loaderData.exampleFertilizerApplication + } /> ) : (
@@ -435,9 +598,61 @@ 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 = + 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.`, + }) + } + + const fieldIds = [ + ...new Set( + url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? + [], + ), + ] if (!fieldIds || fieldIds.length === 0) { return dataWithError(null, "Selecteer eerst een perceel.") } @@ -447,19 +662,23 @@ export async function action({ request, params }: ActionFunctionArgs) { FormSchema, ) - for (const fieldId of fieldIds) { - await addFertilizerApplication( - fdm, - session.principal_id, - fieldId, - validatedData.p_id, - validatedData.p_app_amount, - validatedData.p_app_method, - validatedData.p_app_date, - ) - } + await 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(`/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.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..edb0c99d7 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.modify_fertilizer.$p_id.tsx @@ -0,0 +1,38 @@ +import { useLoaderData, useNavigate } from "react-router" +import { FertilizerApplicationListDialog } from "~/components/blocks/fertilizer-applications/dialog" +import type { Route as UpstreamRoute } from "./+types/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id" +import { + action as originalAction, + loader as originalLoader, +} from "./farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id" + +export async function loader(props: UpstreamRoute.LoaderArgs) { + return originalLoader(props) +} + +export default function FertilizerApplicationListDialogRoute() { + const { + isForRotation, + numFields, + fertilizer, + fertilizerApplications, + returnUrl, + } = useLoaderData() + + const navigate = useNavigate() + + return ( + navigate("..")} + /> + ) +} + +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..bbdea4061 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,31 @@ 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.startsWith(`${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 +218,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 +258,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 new file mode 100644 index 000000000..0b2d001b7 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx @@ -0,0 +1,189 @@ +import { + checkPermission, + getFertilizer, + getFertilizerApplications, + getFertilizerParametersDescription, + getField, + removeFertilizerApplication, +} from "@nmi-agro/fdm-core" +import { data, useLoaderData, useNavigate } from "react-router" +import { dataWithSuccess } from "remix-toast" +import z from "zod" +import { FertilizerApplicationListDialog } from "~/components/blocks/fertilizer-applications/dialog" +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" + +interface FertilizerInfo { + p_id: string + p_name_nl: string | null +} + +export async function loader({ params, request }: Route.LoaderArgs) { + try { + const session = await getSession(request) + + const url = new URL(request.url) + + const fieldIds = url.searchParams + .get("fieldIds") + ?.split(",") + .filter((b_id) => b_id.length > 0) + if (!fieldIds || fieldIds.length === 0) { + throw data("missing: fieldIds", { + status: 400, + statusText: "missing: fieldIds", + }) + } + + const originalFertilizer = await getFertilizer(fdm, params.p_id) + const fertilizer: FertilizerInfo = { + p_id: originalFertilizer.p_id, + p_name_nl: originalFertilizer.p_name_nl, + } + + const allApplicationsPerField = await Promise.all( + fieldIds.map(async (b_id) => { + const field = await getField(fdm, session.principal_id, b_id) + return getFertilizerApplications( + fdm, + session.principal_id, + b_id, + ).then((apps) => + apps.map((app) => ({ + ...app, + b_id: b_id, + b_name: field.b_name, + })), + ) + }), + ) + + 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 = + getFertilizerParametersDescription() + const applicationMethods = fertilizerParameterDescription.find( + (x: { parameter: string }) => + x.parameter === "p_app_method_options", + ) + if (!applicationMethods) throw new Error("Parameter metadata missing") + + const applicationsExtended = await Promise.all( + 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}` + + return { + isForRotation: !url.pathname.includes("/field/"), + numFields: fieldIds.length, + fertilizer: fertilizer, + fertilizerApplications: applicationsExtended, + returnUrl: returnUrl, + } + } catch (e) { + throw handleLoaderError(e) + } +} + +export default function FertilizerApplicationListDialogRoute() { + const { + isForRotation, + numFields, + fertilizer, + fertilizerApplications, + returnUrl, + } = useLoaderData() + + const navigate = useNavigate() + + return ( + navigate("..")} + /> + ) +} + +const FormSchema = z.discriminatedUnion("intent", [ + z.object({ + intent: z.literal("remove_application"), + appIds: z + .string() + .transform(parseAppIds) + .refine((ids) => ids.length > 0, { + error: "missing: appIds", + }), + }), +]) + +export async function action({ request }: Route.ActionArgs) { + try { + const session = await getSession(request) + + const formData = await extractFormValuesFromRequest(request, FormSchema) + + if (formData.intent === "remove_application") { + await 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.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 new file mode 100644 index 000000000..ff68355dd --- /dev/null +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx @@ -0,0 +1,15 @@ +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" + +export async function loader(props: UpstreamRoute.LoaderArgs) { + return originalLoader(props) +} + +export default ModifyFertilizer + +export async function action(props: UpstreamRoute.ActionArgs) { + return originalAction(props) +}