Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
0815269
Add modify fertilizer applications dialog
BoraIneviNMI Feb 23, 2026
d67a7d6
Fix grouping and a few other things
BoraIneviNMI Feb 23, 2026
b6f7cd5
Add changeset
BoraIneviNMI Feb 23, 2026
2f4b12e
Add empty table fallback and fix the routes for farm create wizard
BoraIneviNMI Feb 23, 2026
5d265cd
Merge branch 'development' into FDM440
BoraIneviNMI Feb 23, 2026
c01b075
Await transactions
BoraIneviNMI Feb 23, 2026
6c65c83
Make appIds search param use consistent
BoraIneviNMI Feb 23, 2026
2d4e755
Add spinner when deleting fertilizer application
BoraIneviNMI Feb 23, 2026
2ef5eee
Nitpicks
BoraIneviNMI Feb 23, 2026
c84cdef
Merge branch 'FDM440' of github.com:SvenVw/fdm into FDM440
BoraIneviNMI Feb 23, 2026
f135c32
Fix partial updates code and resolve some nitpicks
BoraIneviNMI Feb 23, 2026
d1a6df6
Merge branch 'development' into FDM440
BoraIneviNMI Feb 24, 2026
0fa2bfe
Improve type safety when extracting form data
BoraIneviNMI Feb 24, 2026
fbc31db
Fix merge error
BoraIneviNMI Feb 24, 2026
0863517
Address comments
BoraIneviNMI Feb 24, 2026
20a4c61
Add grouping method selector
BoraIneviNMI Feb 24, 2026
7cabdc3
Change some things
BoraIneviNMI Feb 24, 2026
38bbdd8
Add field names column and various conditional messages
BoraIneviNMI Feb 24, 2026
484e7db
Use react table for the fertilizer application table and add it to th…
BoraIneviNMI Feb 25, 2026
38fda10
Nitpicks
BoraIneviNMI Feb 25, 2026
a7b8363
Oops
BoraIneviNMI Feb 25, 2026
3927d21
Group by similar applications and remove option to choose grouping
BoraIneviNMI Feb 26, 2026
b014f42
Add conditional rendering of the fertilizer application modification …
BoraIneviNMI Feb 26, 2026
57c3dfc
Add documentation
BoraIneviNMI Feb 26, 2026
6a083a0
Use field fertilizer add page since it is more functionally similar
BoraIneviNMI Feb 26, 2026
6119a3e
Nitpicks in column.tsx
BoraIneviNMI Feb 26, 2026
8c1b124
Nitpicks on the field fertilizer add page
BoraIneviNMI Feb 26, 2026
f6f8b22
Harden isOfOrigin
BoraIneviNMI Feb 26, 2026
16c7cdb
Remove memoization from table cells
BoraIneviNMI Feb 26, 2026
31401ef
Import format
BoraIneviNMI Feb 26, 2026
3d4cedc
Change some logic
BoraIneviNMI Feb 26, 2026
e8c4cdf
Harden isOfOrigin
BoraIneviNMI Feb 26, 2026
9e67b85
Merge branch 'development' into FDM440
BoraIneviNMI Feb 26, 2026
ceae844
Nitpicks
BoraIneviNMI Feb 26, 2026
40d61b3
Nitpicks
BoraIneviNMI Feb 26, 2026
416619a
Add more documentation
BoraIneviNMI Feb 26, 2026
b638a17
fix: text improvements
SvenVw Feb 27, 2026
2dbf929
refactor: move FertilizerApplicationListDialog into seperate componen…
SvenVw Feb 27, 2026
bb3c4aa
fix: Empty-state text is singular in a possible multi-field context
SvenVw Feb 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quick-dots-tease.md
Original file line number Diff line number Diff line change
@@ -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.
262 changes: 262 additions & 0 deletions fdm-app/app/components/blocks/fertilizer-applications/columns.tsx
Original file line number Diff line number Diff line change
@@ -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 })}`
}
Comment thread
BoraIneviNMI marked this conversation as resolved.

/**
* 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<FertAppRecordItem>[] = [
{
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="-ms-4">
<span className="text-muted-foreground">
{fieldNames.length} percelen
</span>
<ChevronDown className="ml-1 size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<ScrollArea
className={
fieldNames.length >= 8
? "h-72 overflow-y-auto w-48"
: "w-48"
}
>
<div className="grid grid-cols-1 gap-2">
{fieldNames.map(([b_id, b_name]) => (
<DropdownMenuItem key={b_id}>
{b_name}
</DropdownMenuItem>
))}
</div>
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
{
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) && (
<ModifyCell {...ctx} />
),
},
]

/**
* 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<FertAppRecordItem, unknown>) {
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 (
<div className="flex items-center justify-end gap-2">
<Spinner
className={cn(
"h-4 w-4",
fetcher.state !== "submitting" && "invisible",
)}
/>
<Button asChild>
<NavLink
to={`/farm/${params.b_id_farm}/${params.calendar}/field/fertilizer?appIds=${encodeURIComponent(modifiableAppIds)}&returnUrl=${encodeURIComponent(returnUrl)}`}
>
Wijzigen
</NavLink>
</Button>
<Button
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={fetcher.state === "submitting"}
>
Verwijderen
</Button>
<AlertDialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{modifiableApps.length === 1
? "Bemesting verwijderen?"
: `${modifiableApps.length} bemestingen verwijderen?`}
</AlertDialogTitle>
<AlertDialogDescription>
{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.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuleren</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
const formData = new FormData()
formData.set("appIds", modifiableAppIds)
formData.set("intent", "remove_application")
fetcher.submit(formData, { method: "POST" })
}}
>
Verwijderen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
106 changes: 106 additions & 0 deletions fdm-app/app/components/blocks/fertilizer-applications/dialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-4xl transition-transform duration-1000">
<DialogHeader>
<DialogTitle>
{fertilizer.p_name_nl}
{titleSuffix}
</DialogTitle>
<DialogDescription>
Bekijk en beheer de bemestingen met deze meststof.
</DialogDescription>
</DialogHeader>
{numFertilizerApplications > 0 ? (
<ScrollArea className="max-h-[60vh]">
<DataTable
numFields={numFields}
fertilizerApplications={fertilizerApplications}
returnUrl={returnUrl}
/>
</ScrollArea>
) : (
<Empty>
<EmptyHeader>
<EmptyTitle>Geen bemestingen gevonden</EmptyTitle>
<EmptyDescription>
{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."}
</EmptyDescription>
<EmptyDescription>
Sluit dit venster om een nieuwe bemesting toe te
voegen.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" type="button">
Sluiten
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
Loading