diff --git a/.changeset/purple-dryers-rule.md b/.changeset/purple-dryers-rule.md new file mode 100644 index 000000000..6a28f6c51 --- /dev/null +++ b/.changeset/purple-dryers-rule.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": minor +--- + +Adds a new farm dashboard page with an overview of the farm and links to apps, data pages, and quick actions. \ No newline at end of file diff --git a/.changeset/shaggy-snails-tickle.md b/.changeset/shaggy-snails-tickle.md new file mode 100644 index 000000000..60adfee65 --- /dev/null +++ b/.changeset/shaggy-snails-tickle.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": minor +--- + +Add a new page to apply a fertilizer application to multiple fields at once. diff --git a/.changeset/wicked-boats-hope.md b/.changeset/wicked-boats-hope.md new file mode 100644 index 000000000..b7d61ee7c --- /dev/null +++ b/.changeset/wicked-boats-hope.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": minor +--- + +Add a new page showing an advanced table for the fields of the farm, including searching on field name, cultivations, and fertilizers. It also includes multi‑selection of fields to add a new fertilizer application. diff --git a/fdm-app/app/components/blocks/fields/column-header.tsx b/fdm-app/app/components/blocks/fields/column-header.tsx new file mode 100644 index 000000000..27922207e --- /dev/null +++ b/fdm-app/app/components/blocks/fields/column-header.tsx @@ -0,0 +1,71 @@ +import type { Column } from "@tanstack/react-table" +import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from "lucide-react" +import { Button } from "~/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" +import { cn } from "~/lib/utils" + +interface DataTableColumnHeaderProps + extends React.HTMLAttributes { + column: Column + title: string +} + +export function DataTableColumnHeader({ + column, + title, + className, +}: DataTableColumnHeaderProps) { + if (!column.getCanSort()) { + return
{title}
+ } + + return ( +
+ + + + + + column.toggleSorting(false)} + > + + Oplopend + + column.toggleSorting(true)} + > + + Aflopend + + + column.toggleVisibility(false)} + > + + Verberg + + + +
+ ) +} diff --git a/fdm-app/app/components/blocks/fields/columns.tsx b/fdm-app/app/components/blocks/fields/columns.tsx new file mode 100644 index 000000000..74003fe12 --- /dev/null +++ b/fdm-app/app/components/blocks/fields/columns.tsx @@ -0,0 +1,259 @@ +import type { ColumnDef } from "@tanstack/react-table" +import { MoreHorizontal, ArrowUpRightFromSquare } from "lucide-react" +import { NavLink } from "react-router-dom" +import { Badge } from "~/components/ui/badge" +import { DataTableColumnHeader } from "./column-header" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" +import { Button } from "~/components/ui/button" +import { Checkbox } from "~/components/ui/checkbox" +import { getCultivationColor } from "~/components/custom/cultivation-colors" + +export type FieldExtended = { + b_id: string + b_name: string + cultivations: { + b_lu_name: string + b_lu_croprotation: string + b_lu_start: Date + }[] + fertilizerApplications: { + p_name_nl: string + }[] + a_som_loi: number + b_soiltype_agr: string + b_area: number +} + +export const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + + table.toggleAllPageRowsSelected(!!value) + } + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "b_name", + enableSorting: true, + header: ({ column }) => { + return + }, + cell: ({ row }) => { + const field = row.original + + return ( + + {field.b_name} + + + ) + }, + }, + { + accessorKey: "cultivations", + enableSorting: true, + sortingFn: (rowA, rowB, columnId) => { + const cultivationA = rowA.original.cultivations[0]?.b_lu_name || "" + const cultivationB = rowB.original.cultivations[0]?.b_lu_name || "" + return cultivationA.localeCompare(cultivationB) + }, + header: ({ column }) => { + return + }, + cell: ({ row }) => { + const field = row.original + + const cultivationsSorted = [...field.cultivations].sort((a, b) => + a.b_lu_name.localeCompare(b.b_lu_name), + ) + + return ( +
+ {cultivationsSorted.map((cultivation, idx) => ( + + {cultivation.b_lu_name} + + ))} +
+ ) + }, + enableHiding: true, // Enable hiding for mobile + }, + { + accessorKey: "fertilizerApplications", + enableSorting: true, + sortingFn: (rowA, rowB, columnId) => { + const fertilizerA = + rowA.original.fertilizerApplications[0]?.p_name_nl || "" + const fertilizerB = + rowB.original.fertilizerApplications[0]?.p_name_nl || "" + return fertilizerA.localeCompare(fertilizerB) + }, + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const field = row.original + + const uniqueFertilizerNames = [...field.fertilizerApplications] + .map((app) => app.p_name_nl) + .filter((name, index, self) => self.indexOf(name) === index) + .sort((a, b) => a.localeCompare(b)) + + return ( +
+ {uniqueFertilizerNames.map((fertilizer) => ( + + {fertilizer} + + ))} +
+ ) + }, + enableHiding: true, // Enable hiding for mobile + }, + { + accessorKey: "a_som_loi", + enableSorting: true, + sortingFn: "alphanumeric", + header: ({ column }) => { + return + }, + enableHiding: true, // Enable hiding for mobile + cell: ({ row }) => { + const field = row.original + return ( +

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

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

{field.b_soiltype_agr}

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

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

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

{fertilizerTooltipContent}

+
+
+
+ + + + + + + + +

Voeg een nieuw perceel toe

+
+
+
+
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext(), + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + handleRowClick(row, event) + } + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + Geen resultaten. + + + )} + +
+
+
+ ) +} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx index 2cad97af0..48deff5c8 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx @@ -1,4 +1,10 @@ -import { getFarms, getFields } from "@svenvw/fdm-core" +import { + getCultivations, + getCurrentSoilData, + getFarms, + getFertilizerApplications, + getFields, +} from "@svenvw/fdm-core" import { data, type LoaderFunctionArgs, @@ -10,23 +16,17 @@ import { import { FarmTitle } from "~/components/blocks/farm/farm-title" import { Header } from "~/components/blocks/header/base" import { HeaderFarm } from "~/components/blocks/header/farm" -import { HeaderField } from "~/components/blocks/header/field" import { Button } from "~/components/ui/button" -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, -} from "~/components/ui/card" -import { Separator } from "~/components/ui/separator" import { SidebarInset } from "~/components/ui/sidebar" import { getSession } from "~/lib/auth.server" import { getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" -import { getTimeBasedGreeting } from "~/lib/greetings" +import { DataTable } from "~/components/blocks/fields/table" +import { columns } from "~/components/blocks/fields/columns" +import { FarmContent } from "~/components/blocks/farm/farm-content" +import { BreadcrumbItem, BreadcrumbSeparator } from "~/components/ui/breadcrumb" export const meta: MetaFunction = () => { return [ @@ -110,11 +110,49 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } }) + const fieldsExtended = await Promise.all( + fields.map(async (field) => { + const cultivations = await getCultivations( + fdm, + session.principal_id, + field.b_id, + timeframe, + ) + + const fertilizerApplications = await getFertilizerApplications( + fdm, + session.principal_id, + field.b_id, + timeframe, + ) + + const currentSoilData = await getCurrentSoilData( + fdm, + session.principal_id, + field.b_id, + timeframe, + ) + const a_som_loi = currentSoilData.find(x => x.parameter === "a_som_loi")?.value ?? null + const b_soiltype_agr = currentSoilData.find(x => x.parameter === "b_soiltype_agr")?.value ?? null + + return { + b_id: field.b_id, + b_name: field.b_name, + cultivations: cultivations, + fertilizerApplications: fertilizerApplications, + a_som_loi: a_som_loi, + b_soiltype_agr: b_soiltype_agr, + b_area: Math.round(field.b_area * 10) / 10, + } + }), + ) + // Return user information from loader return { b_id_farm: b_id_farm, farmOptions: farmOptions, fieldOptions: fieldOptions, + fieldsExtended: fieldsExtended, userName: session.userName, } } catch (error) { @@ -135,7 +173,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) { */ export default function FarmFieldIndex() { const loaderData = useLoaderData() - const greeting = getTimeBasedGreeting() + + const currentFarmName = + loaderData.farmOptions.find( + (farm) => farm.b_id_farm === loaderData.b_id_farm, + )?.b_name_farm ?? "" return ( @@ -150,18 +192,18 @@ export default function FarmFieldIndex() { b_id_farm={loaderData.b_id_farm} farmOptions={loaderData.farmOptions} /> - + + + + Percelen +
{loaderData.fieldOptions.length === 0 ? ( <>
@@ -182,72 +224,17 @@ export default function FarmFieldIndex() { ) : ( <> -
- - - {/* Bedrijven */} - - Kies een perceel om verder te gaan - - - -
-
- {loaderData.fieldOptions.map( - (option) => ( -
-
-

- {option.b_name} -

-

- {option.b_area && - option.b_area > - 0.1 - ? `${option.b_area} ha` - : "< 0.1 ha"} -

-
- -
- -
-
- ), - )} -
-
-
- - -

- Of maak een nieuw perceel aan: -

-
- - - -
-
-
-
+ +
+ +
+
)}
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer.tsx new file mode 100644 index 000000000..7f418269b --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer.tsx @@ -0,0 +1,466 @@ +import { + addFertilizerApplication, + getFarms, + getFertilizerParametersDescription, + getFertilizers, + getFields, +} from "@svenvw/fdm-core" +import { + data, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction, + NavLink, + redirect, + useLoaderData, + useLocation, + useNavigation, + useSearchParams, +} from "react-router" +import { FarmTitle } from "~/components/blocks/farm/farm-title" +import { Header } from "~/components/blocks/header/base" +import { HeaderFarm } from "~/components/blocks/header/farm" +import { Button } from "~/components/ui/button" +import { SidebarInset } from "~/components/ui/sidebar" +import { getSession } from "~/lib/auth.server" +import { getCalendar, getTimeframe } from "~/lib/calendar" +import { clientConfig } from "~/lib/config" +import { handleActionError, handleLoaderError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" +import { FarmContent } from "~/components/blocks/farm/farm-content" +import { BreadcrumbItem, BreadcrumbSeparator } from "~/components/ui/breadcrumb" +import { FertilizerApplicationForm } from "~/components/blocks/fertilizer-applications/form" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { Badge } from "~/components/ui/badge" +import { useState } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog" +import { Checkbox } from "~/components/ui/checkbox" +import { Label } from "~/components/ui/label" +import { FormSchema } from "~/components/blocks/fertilizer-applications/formschema" +import { dataWithError, redirectWithSuccess } from "remix-toast" +import { z } from "zod" +import { Info } from "lucide-react" +import { LoadingSpinner } from "~/components/custom/loadingspinner" +import { extractFormValuesFromRequest } from "~/lib/form" + +export const meta: MetaFunction = () => { + return [ + { title: `Bemesting toevoegen | ${clientConfig.name}` }, + { + name: "description", + content: "", + }, + ] +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + // Get the active farm + const b_id_farm = params.b_id_farm + if (!b_id_farm) { + throw data("missing: b_id_farm", { + status: 400, + statusText: "missing: b_id_farm", + }) + } + + // Get fieldIds from search params + const url = new URL(request.url) + const fieldIds = + url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? [] + + // Get the session + const session = await getSession(request) + + // Get timeframe from calendar store + const timeframe = getTimeframe(params) + const calendar = getCalendar(params) + + // Get a list of possible farms of the user + const farms = await getFarms(fdm, session.principal_id) + + // Redirect to farms overview if user has no farm + if (farms.length === 0) { + return redirect("./farm") + } + + // Get farms to be selected + const farmOptions = farms.map((farm) => { + if (!farm?.b_id_farm || !farm?.b_name_farm) { + throw new Error("Invalid farm data structure") + } + return { + b_id_farm: farm.b_id_farm, + b_name_farm: farm.b_name_farm, + } + }) + + // Get the fields to be selected + const fields = await getFields( + fdm, + session.principal_id, + b_id_farm, + timeframe, + ) + const selectedFields = fields.filter( + (field) => field.b_id && fieldIds.includes(field.b_id), + ) + + 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, + } + }) + + // Get available fertilizers for the farm + const fertilizers = await getFertilizers( + fdm, + session.principal_id, + b_id_farm, + ) + const fertilizerParameterDescription = + getFertilizerParametersDescription() + const applicationMethods = fertilizerParameterDescription.find( + (x) => x.parameter === "p_app_method_options", + ) + if (!applicationMethods) throw new Error("Parameter metadata missing") + // Map fertilizers to options for the combobox + const fertilizerOptions = fertilizers.map((fertilizer) => { + const applicationMethodOptions = fertilizer.p_app_method_options + .map((opt) => { + const meta = applicationMethods.options.find( + (x) => x.value === opt, + ) + return meta ? { value: opt, label: meta.label } : undefined + }) + .filter( + (option): option is { value: string; label: string } => + option !== undefined, + ) + return { + value: fertilizer.p_id, + label: fertilizer.p_name_nl, + applicationMethodOptions: applicationMethodOptions, + } + }) + + // Return user information from loader + return { + b_id_farm: b_id_farm, + farmOptions: farmOptions, + fieldAmount: selectedFields.length, + fertilizerOptions: fertilizerOptions, + calendar: calendar, + selectedFields: selectedFields.map((field) => ({ + b_id: field.b_id, + b_name: field.b_name, + b_area: Math.round(field.b_area * 10) / 10, + })), + fieldOptions: fieldOptions, + } + } catch (error) { + throw handleLoaderError(error) + } +} + +export default function FarmFieldFertilizerAddIndex() { + const loaderData = useLoaderData() + const navigation = useNavigation() + const location = useLocation() + const [searchParams, setSearchParams] = useSearchParams() + const [open, setOpen] = useState(false) + const [selectedFieldIds, setSelectedFieldIds] = useState( + loaderData.selectedFields.map((f) => f.b_id!), + ) + + const isSubmitting = navigation.state === "submitting" + + const handleSelectionChange = () => { + const newSearchParams = new URLSearchParams(searchParams) + newSearchParams.set("fieldIds", selectedFieldIds.join(",")) + setSearchParams(newSearchParams, { preventScrollReset: true }) + setOpen(false) + } + + const isSelected = (fieldId: string) => selectedFieldIds.includes(fieldId) + + const toggleSelection = (fieldId: string) => { + setSelectedFieldIds((prev) => + isSelected(fieldId) + ? prev.filter((id) => id !== fieldId) + : [...prev, fieldId], + ) + } + + return ( + +
+ + + + Percelen + + + + Bemesting toevoegen + +
+
+ +
+ {isSubmitting && ( +
+
+ + Bemesting wordt toegevoegd... +
+
+ )} + +
+ + + + Geselecteerde percelen + + + De bemesting wordt toegepast op de + volgende percelen. + + + + {loaderData.selectedFields.length > 0 ? ( +
+ {loaderData.selectedFields.map( + (field) => ( +
+

+ {field.b_name} +

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

+ Geen percelen geselecteerd +

+

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

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

+ Selecteer eerst percelen in de + linkerkolom. +

+
+ )} +
+
+
+
+
+
+
+ ) +} + +export async function action({ request, params }: ActionFunctionArgs) { + try { + const { b_id_farm, calendar = "all" } = params + if (!b_id_farm) { + throw new Error("Farm ID is missing") + } + + const session = await getSession(request) + const url = new URL(request.url) + const fieldIds = + url.searchParams.get("fieldIds")?.split(",").filter(Boolean) ?? [] + + if (!fieldIds || fieldIds.length === 0) { + return dataWithError(null, "Selecteer eerst een perceel.") + } + + const validatedData = await extractFormValuesFromRequest( + request, + FormSchema, + ) + + for (const fieldId of fieldIds) { + await addFertilizerApplication( + fdm, + session.principal_id, + fieldId, + validatedData.p_id, + validatedData.p_app_amount, + validatedData.p_app_method, + validatedData.p_app_date, + ) + } + + return redirectWithSuccess(`/farm/${b_id_farm}/${calendar}/field`, { + message: `Bemesting succesvol toegevoegd aan ${fieldIds.length} ${fieldIds.length === 1 ? "perceel" : "percelen"}.`, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return dataWithError( + null, + "Invoer is ongeldig. Controleer het formulier.", + ) + } + throw handleActionError(error) + } +} diff --git a/fdm-app/app/routes/farm.$b_id_farm._index.tsx b/fdm-app/app/routes/farm.$b_id_farm._index.tsx index 02b945df0..66d77a336 100644 --- a/fdm-app/app/routes/farm.$b_id_farm._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm._index.tsx @@ -1,6 +1,457 @@ -import { redirect } from "react-router" +import { + data, + NavLink, + Outlet, + useLoaderData, + type LoaderFunctionArgs, + type MetaFunction, +} from "react-router" +import { getSession } from "~/lib/auth.server" +import { clientConfig } from "~/lib/config" +import { handleActionError, handleLoaderError } from "~/lib/error" +import { useCalendarStore } from "../store/calendar" +import { SidebarInset } from "../components/ui/sidebar" +import { Header } from "../components/blocks/header/base" +import { HeaderFarm } from "../components/blocks/header/farm" +import { FarmTitle } from "../components/blocks/farm/farm-title" +import { FarmContent } from "../components/blocks/farm/farm-content" +import { getFarm, getFarms, getFields } from "@svenvw/fdm-core" +import { fdm } from "../lib/fdm.server" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "../components/ui/card" +import { Button } from "../components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../components/ui/select" +import { + ArrowRightLeft, + BookOpenText, + ChevronsUp, + ChevronUp, + Home, + Landmark, + MapIcon, + Plus, + Settings, + Shapes, + Square, + Trash2, + UserRoundCheck, + Zap, +} from "lucide-react" +import { getCalendarSelection } from "../lib/calendar" -export async function loader() { - // Redirect to settings page - return redirect("./settings") +// Meta +export const meta: MetaFunction = () => { + return [ + { title: `Bedrijf | ${clientConfig.name}` }, + { + name: "description", + content: "Bekijk en bewerk de gegevens van je bedrijf.", + }, + ] +} + +/** + * Processes a request to retrieve a farm's session details. + * + * This function extracts the farm ID from the route parameters and throws an error with a 400 status + * if the ID is missing. When a valid farm ID is provided, it retrieves the session associated with the + * incoming request and returns an object containing both the farm ID and the session information. + * + * @returns An object with "farmId" and "session" properties. + * + * @throws {Response} If the farm ID is not provided. + */ +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + // Get the farm id + const b_id_farm = params.b_id_farm + if (!b_id_farm) { + throw data("Farm ID is required", { + status: 400, + statusText: "Farm ID is required", + }) + } + + // Get the session + const session = await getSession(request) + + // Get the farm details + const farm = await getFarm(fdm, session.principal_id, b_id_farm) + + // Get the list of fields + const fields = await getFields(fdm, session.principal_id, b_id_farm) + + // Calculate total area for this farm + const farmArea = fields.reduce((acc, field) => acc + field.b_area, 0) + + // Get a list of possible farms of the user + const farms = await getFarms(fdm, session.principal_id) + const farmOptions = farms.map((farm) => { + return { + b_id_farm: farm.b_id_farm, + b_name_farm: farm.b_name_farm, + } + }) + + // Return the farm ID and session info + return { + b_id_farm: b_id_farm, + b_name_farm: farm.b_name_farm, + fieldsNumber: fields.length, + farmArea: Math.round(farmArea), + farmOptions: farmOptions, + roles: farm.roles, + } + } catch (error) { + throw handleLoaderError(error) + } +} + +export default function FarmDashboardIndex() { + const loaderData = useLoaderData() + + const calendar = useCalendarStore((state) => state.calendar) + const setCalendar = useCalendarStore((state) => state.setCalendar) + const years = getCalendarSelection() + + return ( + +
+ +
+
+ + +
+ {/* Left Column */} +
+ {/* Quick Actions */} +
+

+ Snelle acties +

+
+ + + +
+
+ +
+
+ + Bemesting toevoegen + + + Voor één of meerdere + percelen. + +
+
+
+
+
+ + + +
+
+ +
+
+ + Perceelsoverzicht + + + Uitgebreide tabel met o.a. gewassen en meststoffen per perceel. + +
+
+
+
+
+
+
+ + {/* Apps */} +
+

+ Apps +

+
+ + + +
+
+ +
+
+ + Nutriëntenbalans + + + Aanvoer, afvoer en + emissie van + stikstof. + +
+
+
+
+
+ + + +
+
+ +
+
+ + Bemestingsadvies + + + Volgens Handboek + Bodem en Bemesting + en Adviesbasis + Bemesting. + +
+
+
+
+
+ + + +
+
+ +
+
+ + Gebruiksnormen + + + Normen op bedrijfs- + en perceelsniveau. + +
+
+
+
+
+ + + +
+
+ +
+
+ + Atlas + + + Gewaspercelen op de + kaart. + +
+
+
+
+
+
+
+
+ + {/* Right Column */} +
+ {/* Overview */} +
+

+ Overzicht +

+ + +
+
+ + Aantal percelen + + + {loaderData.fieldsNumber} + +
+
+ + Totale oppervlakte + + + {loaderData.farmArea} ha + +
+ +
+ + Rol + + + {loaderData.roles.includes( + "owner", + ) + ? "Eigenaar" + : loaderData.roles.includes( + "advisor", + ) + ? "Adviseur" + : loaderData.roles.includes( + "researcher", + ) + ? "Onderzoeker" + : loaderData + .roles[0]} + +
+
+ + Jaar + + +
+
+
+
+
+ + {/* Settings */} +
+

+ Gegevens +

+ + +
+ + + + + + +
+
+
+
+
+
+
+
+
+ ) } diff --git a/fdm-app/app/routes/farm._index.tsx b/fdm-app/app/routes/farm._index.tsx index 2c182c72a..eeb3164f3 100644 --- a/fdm-app/app/routes/farm._index.tsx +++ b/fdm-app/app/routes/farm._index.tsx @@ -25,7 +25,7 @@ import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { getTimeBasedGreeting } from "~/lib/greetings" -import { getCalendarSelection } from "../lib/calendar" +import { getCalendarSelection } from "~/lib/calendar" // Meta export const meta: MetaFunction = () => { diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx index 3cdf98ce2..d7c5c0196 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx @@ -17,13 +17,6 @@ import { import { dataWithError, dataWithSuccess } from "remix-toast" import { AccessInfoCard } from "~/components/blocks/access/access-info-card" import { AccessManagementCard } from "~/components/blocks/access/access-management-card" -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbSeparator, -} from "~/components/ui/breadcrumb" import { Button } from "~/components/ui/button" import { Separator } from "~/components/ui/separator" import { getSession } from "~/lib/auth.server" diff --git a/fdm-app/package.json b/fdm-app/package.json index 5a92d3ad3..ca36fe697 100644 --- a/fdm-app/package.json +++ b/fdm-app/package.json @@ -45,6 +45,7 @@ "file-type": "^21.0.0", "flatgeobuf": "^4.2.0", "framer-motion": "^12.23.14", + "fuzzysort": "^3.1.0", "isbot": "^5.1.30", "lodash.throttle": "^4.1.1", "lucide-react": "^0.544.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57eefdeec..83f716e8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,9 @@ importers: framer-motion: specifier: ^12.23.14 version: 12.23.14(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + fuzzysort: + specifier: ^3.1.0 + version: 3.1.0 isbot: specifier: ^5.1.30 version: 5.1.30 @@ -6712,6 +6715,9 @@ packages: resolution: {integrity: sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==} engines: {node: '>= 0.6.0'} + fuzzysort@3.1.0: + resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -18744,6 +18750,8 @@ snapshots: fuzzy@0.1.3: {} + fuzzysort@3.1.0: {} + gensync@1.0.0-beta.2: {} geojson-equality-ts@1.0.2: