Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
0f41a5b
Add prototype for organization balance page
BoraIneviNMI Mar 13, 2026
6d4a418
Use batched computation of farm nitrogen balances for organizations
BoraIneviNMI Mar 16, 2026
b62603b
Move the multiple-farm calculation function into fdm-calculator
BoraIneviNMI Mar 16, 2026
6429005
Adapt fdm-app to changes
BoraIneviNMI Mar 16, 2026
d5bd97a
Add testing WIP
BoraIneviNMI Mar 17, 2026
0160200
Fix tests
BoraIneviNMI Mar 17, 2026
a5f0570
Update organization nitrogen balance page
BoraIneviNMI Mar 17, 2026
dea8ba4
Merge branch 'development' into FDM493
SvenVw Mar 18, 2026
3f04292
Add sidebar and adjust text in organization nitrogen balance chart
BoraIneviNMI Mar 18, 2026
b5d00ff
Move aggregation of farm results to fdm-calculator
BoraIneviNMI Mar 19, 2026
8a1053d
Add tests
BoraIneviNMI Mar 19, 2026
81b658c
Add tests for farm nitrogen balance input
BoraIneviNMI Mar 19, 2026
74d060e
Reuse the batch logic for calculateNitrogenBalance
BoraIneviNMI Mar 19, 2026
40afd8e
Fix failing test
BoraIneviNMI Mar 19, 2026
0e271a8
Move fertilizer chunking logic to fdm-core and test it
BoraIneviNMI Mar 20, 2026
08bdc11
Add more tests and fix some
BoraIneviNMI Mar 20, 2026
0d6e09b
Rename some things and clean up
BoraIneviNMI Mar 20, 2026
35322ea
Add organic matter balance for organisations
BoraIneviNMI Mar 23, 2026
5a040f2
Remove mock email sending
BoraIneviNMI Mar 23, 2026
bed80e5
Nitpicks
BoraIneviNMI Mar 23, 2026
d53bc11
Improve navigation
BoraIneviNMI Mar 23, 2026
430f01a
Move farm select dialog into a separate component
BoraIneviNMI Mar 23, 2026
dcb2e74
fix: cache input inconsistency between one farm and multiple farms
BoraIneviNMI Mar 23, 2026
a8d4e4b
Test getCultivationsOfFarmsFromCatalogue a bit better
BoraIneviNMI Mar 23, 2026
e69e9df
Change the not technically-correct comment about the use(...) hook
BoraIneviNMI Mar 23, 2026
261d0be
fix: dialog does not close when there is no change in the selection o…
BoraIneviNMI Mar 23, 2026
5fe82e0
Typecheck fix
BoraIneviNMI Mar 23, 2026
f4a5904
Address nitpicks
BoraIneviNMI Mar 24, 2026
7f15a84
Sure
BoraIneviNMI Mar 24, 2026
87bb6c6
Merge branch 'development' into FDM493
BoraIneviNMI Mar 24, 2026
3ed182d
Replace getFertilizersOfFarms with getFertilizersFromCatalogueForFarms
BoraIneviNMI Mar 26, 2026
4e04875
Make fertilizer type derivation logic shared
BoraIneviNMI Mar 27, 2026
558a32f
Nitpicks
BoraIneviNMI Mar 27, 2026
3fac203
Replace getCultivationsOfFarmsFromCatalogue with getCultivationsFromC…
BoraIneviNMI Mar 27, 2026
a437b11
Add internal directive to some JSDoc comments
BoraIneviNMI Mar 27, 2026
9d3a4ab
Address nitpicks
BoraIneviNMI Mar 27, 2026
1ca596f
Fix mock fdm
BoraIneviNMI Mar 27, 2026
68c9f1a
Include all farm IDs for getFertilizersFromCatalogueForFarms and getC…
BoraIneviNMI Mar 27, 2026
406c638
Make sure results are sorted by area
BoraIneviNMI Mar 27, 2026
2fdadcc
Nitpicks
BoraIneviNMI Mar 27, 2026
ad92a6e
Display farm areas on the farm select dialog
BoraIneviNMI Mar 27, 2026
6e4e216
Validate farmIds
BoraIneviNMI Mar 27, 2026
ae7d3c9
Add changeset
BoraIneviNMI Mar 27, 2026
3e44624
Merge branch 'development' into FDM493
SvenVw Mar 30, 2026
3403720
Merge branch 'development' into FDM493
SvenVw Mar 31, 2026
83b3970
fix: use "Organische stof" consistent
SvenVw Mar 31, 2026
eeedde8
refactor: improve the new backend functions
SvenVw Mar 31, 2026
48ec6bb
chore: update the changesets
SvenVw Mar 31, 2026
fa074dc
fix: address code review findings in org OM balance route and calcula…
SvenVw Mar 31, 2026
427728e
fix: address code review findings
SvenVw Apr 1, 2026
548af95
fix: db insert in case of race condition
SvenVw Apr 1, 2026
8de9d8a
refactor: address code review comments
SvenVw Apr 1, 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/breezy-spies-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nmi-agro/fdm-core": minor
---

Added `getEnabledCultivationCataloguesForFarms` and `getEnabledFertilizerCataloguesForFarms` to retrieve the enabled catalogues for multiple farms in one query. Added `getCultivationsFromCatalogues` and `getFertilizersFromCatalogues` to fetch catalogue items for a given list of catalogue source IDs. These composable building blocks replace the removed `getCultivationsFromCatalogueForFarms` and `getFertilizersFromCatalogueForFarms` functions.
5 changes: 5 additions & 0 deletions .changeset/green-pumas-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nmi-agro/fdm-app": minor
---

Added organization-level nitrogen and organic matter balance plots with option to exclude certain farms from the calculation.
5 changes: 5 additions & 0 deletions .changeset/thin-steaks-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nmi-agro/fdm-calculator": minor
---

Added `collectInputForNitrogenBalanceForFarms` and `collectInputForOrganicMatterBalanceForFarms` to collect balance inputs for multiple farms, reducing database lookups by deduplicating catalogue queries across farms. The functions use a composable pattern: first fetch enabled catalogues for all farms in one query, then fetch catalogue items once per unique catalogue, then process each farm individually.
136 changes: 136 additions & 0 deletions fdm-app/app/components/blocks/balance/farm-select-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { useRef } from "react"
import { useSearchParams } from "react-router"
import { Button } from "~/components/ui/button"
import { Checkbox } from "~/components/ui/checkbox"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog"

/**
* Renders a button which, when clicked, shows a dialog where the user can change selection of farms included in balance calculation.
*
* - `farms` should be the complete list of farms that the user can select or ignore.
* - `defaultSelectedFarmIds` should be coming from the loader data after validation, and is used to set the initial state of the checkboxes.
*
* The dialog will set the `farmIds` search param directly when the selection changes.
*
* @param param0 component props
* @returns a React node
*/
export function FarmSelectDialog({
farms,
defaultSelectedFarmIds,
}: {
farms: {
b_id_farm: string
b_name_farm: string | null
b_area_farm: number
}[]
defaultSelectedFarmIds: string[]
}) {
const formRef = useRef<HTMLFormElement | null>(null)
const [, setSearchParams] = useSearchParams()

return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Wijzig selectie</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Wijzig selectie van bedrijven</DialogTitle>
<DialogDescription>
De geselecteerde bedrijven zijn uitgesloten in de
berekening.
</DialogDescription>
</DialogHeader>
<form
ref={formRef}
className="space-y-4 max-h-50 overflow-y-scroll"
>
{farms.flatMap((farm) => {
const b_id_farm = farm.b_id_farm
const currentValue = defaultSelectedFarmIds.includes(
farm.b_id_farm,
)
return (
<div
key={farm.b_id_farm}
className="flex flex-row items-center gap-4"
>
<Checkbox
name={b_id_farm}
defaultChecked={!!currentValue}
/>
<div className="grow">
{farm.b_name_farm ?? "Onbekend"}
</div>
<div className="text-muted-foreground">
{Math.round(farm.b_area_farm * 10) / 10} ha
</div>
</div>
)
})}
</form>
<DialogFooter>
<DialogClose
asChild
onClick={() => {
const form = formRef.current

const newlySelectedFarmIds: string[] = []
if (form) {
const formData = new FormData(form)
for (const [
b_id_farm,
selected,
] of formData.entries()) {
if (selected) {
newlySelectedFarmIds.push(b_id_farm)
}
}
}
const sortedDefaultSelectedFarmIds = [
...defaultSelectedFarmIds,
].sort()
newlySelectedFarmIds.sort()
if (
sortedDefaultSelectedFarmIds.length !==
newlySelectedFarmIds.length ||
newlySelectedFarmIds.some(
(selected_id, index) =>
selected_id !==
sortedDefaultSelectedFarmIds[index],
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {
setSearchParams((searchParams) => {
const newSearchParams = new URLSearchParams(
searchParams,
)
if (newlySelectedFarmIds.length > 0) {
newSearchParams.set(
"farmIds",
newlySelectedFarmIds.join(","),
)
} else {
newSearchParams.delete("farmIds")
}
return newSearchParams
})
}
}}
>
<Button variant="outline">Opslaan</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
2 changes: 1 addition & 1 deletion fdm-app/app/components/blocks/balance/nitrogen-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ function buildChartDataAndLegend({
}

export function NitrogenBalanceChart(
props: { balanceData: { balance: number; removal: number } } & (
props: (
| { type: "farm"; balanceData: FarmBalanceData; fieldInput: unknown }
| {
type: "field"
Expand Down
65 changes: 63 additions & 2 deletions fdm-app/app/components/blocks/header/organization.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ChevronDown } from "lucide-react"
import { NavLink, useLocation, useMatches } from "react-router"
import { NavLink, useLocation, useMatches, useParams } from "react-router"
import {
BreadcrumbItem,
BreadcrumbLink,
Expand All @@ -20,9 +20,10 @@ export function HeaderOrganization({
organizationOptions: HeaderOrganizationOption[]
}) {
const location = useLocation()
const params = useParams()
const matches = useMatches()
const currentPath = String(location.pathname)

const matches = useMatches()
const isSettingsRoute = !!matches.find(
(match) => match.id === "routes/organization.$slug.settings",
)
Expand All @@ -40,6 +41,14 @@ export function HeaderOrganization({
const isNewOrganizationRoute = !!matches.find(
(match) => match.id === "routes/organization.new",
)
const typesOfBalanceRoutes = ["nitrogen", "organic-matter"] as const
const farmBalanceRouteType = typesOfBalanceRoutes.find((type) =>
matches.find(
(match) =>
match.id ===
`routes/organization.$slug.$calendar.balance.${type}._index`,
),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<>
Expand Down Expand Up @@ -123,6 +132,58 @@ export function HeaderOrganization({
<BreadcrumbSeparator />
<BreadcrumbItem>Leden</BreadcrumbItem>
</>
) : farmBalanceRouteType ? (
<>
<BreadcrumbSeparator className="hidden xl:block" />
<BreadcrumbItem className="hidden xl:block">
<BreadcrumbLink
href={`/organization/${selectedOrganizationSlug}/${params.calendar}/balance/nitrogen`}
>
Balans
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1 max-w-[120px] sm:max-w-[200px] md:max-w-none outline-none">
<span className="truncate">
{farmBalanceRouteType === "nitrogen"
? "Stikstof"
: "Organische stof"}
</span>
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuCheckboxItem
checked={
farmBalanceRouteType ===
"nitrogen"
}
key={"nitrogen"}
>
<NavLink
to={`/organization/${selectedOrganizationSlug}/${params.calendar}/balance/nitrogen${location.search}`}
>
Stikstof
</NavLink>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={
farmBalanceRouteType ===
"organic-matter"
}
key={"organic-matter"}
>
<NavLink
to={`/organization/${selectedOrganizationSlug}/${params.calendar}/balance/organic-matter${location.search}`}
>
Organische stof
</NavLink>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
</>
) : isFarmsRoute ? (
<>
<BreadcrumbSeparator />
Expand Down
36 changes: 36 additions & 0 deletions fdm-app/app/components/blocks/organization/no-farms-message.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NavLink } from "react-router"
import { Button } from "~/components/ui/button"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "~/components/ui/empty"

export function NoFarmsMessage({
action,
}: {
action?: { label: string; to: string }
}) {
return (
<Empty className="border-none">
<EmptyHeader>
<EmptyTitle>
Het lijkt erop dat je organisatie geen toegang heeft tot
bedrijven. :(
</EmptyTitle>
<EmptyDescription>
Neem contact op met bedrijven om toegang tot hen te krijgen.
</EmptyDescription>
</EmptyHeader>
{action && (
<EmptyContent>
<Button asChild>
<NavLink to={action.to}>{action.label}</NavLink>
</Button>
</EmptyContent>
)}
</Empty>
)
}
Loading
Loading