diff --git a/.github/workflows/deploy-docs-test.yml b/.github/workflows/deploy-docs-test.yml index f2f11bc81..01fd4ca6f 100644 --- a/.github/workflows/deploy-docs-test.yml +++ b/.github/workflows/deploy-docs-test.yml @@ -35,7 +35,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 10.17.0 + version: 10.18.3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index aed5acb37..5f57586ae 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -38,7 +38,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 10.17.0 + version: 10.18.3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: diff --git a/.github/workflows/release-comment.yml b/.github/workflows/release-comment.yml new file mode 100644 index 000000000..4df8af6ca --- /dev/null +++ b/.github/workflows/release-comment.yml @@ -0,0 +1,41 @@ +name: Release PR Comment + +on: + pull_request: + types: [opened, reopened] + branches: + - main # Only trigger for PRs targeting main + +jobs: + add-comment: + runs-on: ubuntu-latest + if: startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/') + permissions: + pull-requests: write + steps: + - name: Add PR Comment + uses: actions/github-script@v7 + with: + script: | + const isHotfix = context.payload.pull_request.head.ref.startsWith('hotfix/'); + const branchType = isHotfix ? 'Hotfix' : 'Release'; + const workflowName = 'Release'; // The name of your workflow file + + const commentBody = ` + 👋 **${branchType} Branch PR Detected!** + + Before merging this Pull Request into \`main\`, please ensure you have finalized the ${branchType.toLowerCase()} by **manually running the '${workflowName}' workflow** on this \`${context.payload.pull_request.head.ref}\` branch. + + This will: + 1. Bump package versions. + 2. Generate changelogs. + 3. Create Git tags. + + You can trigger the workflow from the 'Actions' tab, selecting the '${workflowName}' workflow, and choosing this \`${context.payload.pull_request.head.ref}\` branch. + `; + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: commentBody + }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 064206399..87e74e4bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,61 +1,107 @@ -name: Prerelease +name: Release on: push: - paths: - - 'fdm-core/**' - - 'fdm-data/**' branches: - main + - development + workflow_dispatch: # This enables the manual trigger + pull_request: + branches: + - main # Trigger for pull requests targeting main -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true +concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: - Release: + release: name: Release runs-on: ubuntu-latest - strategy: - matrix: - node-version: [22] permissions: - contents: read + contents: write packages: write + pull-requests: write # Required for the status check steps: - name: Checkout Repo uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Cache turbo build setup - uses: actions/cache@v4 - with: - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-turbo- - - - name: Setup pnpm 10 + - name: Setup pnpm uses: pnpm/action-setup@v4 with: - version: 10.17.0 + version: 10.18.3 - - name: Use Node.js ${{ matrix.node-version }} + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} - registry-url: 'https://npm.pkg.github.com' + node-version: 22 cache: 'pnpm' - name: Install Dependencies - run: pnpm i + run: pnpm install --frozen-lockfile - - name: Bump snapshot - run: pnpm changeset version + # Runs for the 'development' branch on push + - name: Publish Snapshot + if: github.ref == 'refs/heads/development' + run: | + pnpm changeset version --snapshot + pnpm changeset publish --tag development + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Build packages - run: pnpm build --filter=@svenvw/fdm-core --filter=@svenvw/fdm-data --filter=@svenvw/fdm-calculator - - - name: Publish snapshot - run: pnpm changeset publish + # Runs MANUALLY on a 'release/*' or 'hotfix/*' branch + - name: Version Packages + if: github.event_name == 'workflow_dispatch' && (startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/heads/hotfix/')) + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + pnpm changeset version + pnpm changeset tag + git add . + git commit -m "chore: bump version of packages for release" + git push --follow-tags env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Runs for the 'main' branch on push + - name: Publish Packages and Create Releases + if: github.ref == 'refs/heads/main' + run: | + pnpm build --filter="!@svenvw/fdm-app" + pnpm changeset publish --no-git-tag + + git tag --points-at HEAD | while read -r tag; do + gh release create "$tag" --generate-notes + done + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + verify-versioning: + name: Verify Versioning + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) + permissions: + pull-requests: write # Required to update PR status + contents: read # Required to checkout code + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + fetch-depth: 0 + + - name: Check for Versioning Commit + id: check_commit + run: | + if git log --format="%s" -n 1 | grep -q "chore: bump version of packages for release"; then + echo "::notice::Versioning commit found. All good!" + echo "versioning_commit_found=true" >> "$GITHUB_OUTPUT" + else + echo "::error::Versioning commit 'chore: bump version of packages for release' not found. Please run the 'Release' workflow manually on this branch." + echo "versioning_commit_found=false" >> "$GITHUB_OUTPUT" + exit 1 + fi + shell: bash diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8b792301c..b8ec75904 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,7 +53,7 @@ jobs: - name: Setup pnpm 10 uses: pnpm/action-setup@v4 with: - version: 10.17.0 + version: 10.18.3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 @@ -146,7 +146,7 @@ jobs: - name: Setup pnpm 10 uses: pnpm/action-setup@v4 with: - version: 10.17.0 + version: 10.18.3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 @@ -226,7 +226,7 @@ jobs: - name: Setup pnpm 10 uses: pnpm/action-setup@v4 with: - version: 10.17.0 + version: 10.18.3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 diff --git a/fdm-app/CHANGELOG.md b/fdm-app/CHANGELOG.md index 621e9109c..84ece221b 100644 --- a/fdm-app/CHANGELOG.md +++ b/fdm-app/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog fdm-app +## 0.24.0 + +### Minor Changes + +- 9dfde4c: Replace Meststoftype with Mestcode (RVO) at fertilizer forms +- 5e711d7: Improve the design of the list of fertilizer applications by adding an icon for the type of fertilizer and better usage of spacing and aligning +- bf8b0ff: In the fertilizer table show the RVO mestcode (p_type_rvo) colored by p_type instead of only p_type +- 2c9aafa: Add new page at farm details to add whether the farm has an organic certification +- 06314a5: Fixes that user can drop files as well for Mijn Percelen shapefile upload and Soil Analysis pdf upload. +- b71bf41: Users can now edit previously created fertilizer applications, both for individual fields or a given cultivation type. +- 77c309d: In case a field has an error at the nitrogen balance calculation, the balance at farm level and other fields are still shown, but an error message for the specific field is shown +- c1ebe6d: Add new page at farm details to state whether the farmer has done grazing or intends it for a year +- d756cf4: The users can now drop files onto the entire shapefile upload area during farm creation, not just on top of the text and icons. +- d279d08: For cultivations default dates based on the cultivation catalogue date are used for b_lu_start and b_lu_end if available when adding a field with a cultivation +- 276f35a: Show to the farm norms page the filling of the norms as well +- 3eb4ec2: At the fertilizer applications page for a field show the metrics for norms, nitrogen balance and nutrient advice as well +- 73be0f3: Add page to show on field level the norm values and fillings. It shows also the contribution of each fertilizer application to the various norms + +### Patch Changes + +- 1a89d67: Submit "other" errors for loaders and actions to Sentry +- 8f8cc9f: At Nutrient Advice show progress bar for nutrients that have advice of 0 +- beef80c: Submit calculation errors at nitrogen balance calculation to Sentry +- 8b854af: Move `getNutrientAdvice` to fdm-calculator and use the cached version of the function +- 8f8cc9f: At Nutrient Advice show doses with precision of 2 if dose is between 0 and 1 kg / ha instead of showing 0 +- a2f8419: Fix parsing of `b_date` in the response for th soil analysis extraction +- da64906: Fix link in header of fertilizers +- 91d4103: Switch to use the cached version of the calculator functions for `norms` and `balance` +- Updated dependencies [97083dd] +- Updated dependencies [a74a6e8] +- Updated dependencies [a226f7e] +- Updated dependencies [77c309d] +- Updated dependencies [a00a331] +- Updated dependencies [726ae00] +- Updated dependencies [8f9d4ff] +- Updated dependencies [2f7b281] +- Updated dependencies [c939de9] +- Updated dependencies [b58cd07] +- Updated dependencies [77c309d] +- Updated dependencies [d6b8900] +- Updated dependencies [b58cd07] +- Updated dependencies [ac5d94f] +- Updated dependencies [91d4103] +- Updated dependencies [8b2bf8c] +- Updated dependencies [6bcb528] +- Updated dependencies [91d4103] + - @svenvw/fdm-data@0.18.0 + - @svenvw/fdm-calculator@0.8.0 + - @svenvw/fdm-core@0.26.0 + ## 0.23.2 ### Patch Changes diff --git a/fdm-app/app/components/blocks/fertilizer-applications/card.tsx b/fdm-app/app/components/blocks/fertilizer-applications/card.tsx index f6316fba5..f086e2feb 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/card.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/card.tsx @@ -1,11 +1,10 @@ import type { Dose } from "@svenvw/fdm-calculator" +import type { Fertilizer } from "@svenvw/fdm-core" import type { ApplicationMethods } from "@svenvw/fdm-data" -import { format } from "date-fns" -import { Lightbulb, Scale } from "lucide-react" +import { Plus } from "lucide-react" import { useEffect, useRef, useState } from "react" import { useFetcher, useLocation, useNavigation, useParams } from "react-router" import { useFieldFertilizerFormStore } from "@/app/store/field-fertilizer-form" -import { LoadingSpinner } from "~/components/custom/loadingspinner" import { Button } from "~/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card" import { @@ -16,145 +15,33 @@ import { DialogTitle, DialogTrigger, } from "~/components/ui/dialog" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "~/components/ui/tooltip" -import { cn } from "~/lib/utils" import { FertilizerApplicationForm } from "./form" -import type { - FertilizerApplication, - FertilizerApplicationsCardProps, - FertilizerOption, -} from "./types.d" - -function FertilizerApplicationsDetailCard({ - title, - shortname, - value, - unit, - limit, - advice, -}: FertilizerApplicationsCardProps) { - return ( - - - {title} -

{shortname}

-
- -
-
{`${Math.round(value)}`}
-
{unit}
-
-
- - - -
- -

- {`${limit}`} -

-
-
- - {`Gebruiksnorm voor ${title} [${unit}]`} - -
-
- - - -
- -

- {`${advice}`} -

-
-
- - {`Bemestingsadvies voor ${title} [${unit}]`} - -
-
-
-
-
- ) -} - -function constructCards(dose: Dose) { - // Construct the fertilizer application cards - const cards: FertilizerApplicationsCardProps[] = [ - { - title: "Stikstof, totaal", - shortname: "Ntot", - value: dose.p_dose_n, - unit: "kg/ha", - limit: undefined, - advice: undefined, - }, - { - title: "Stikstof, werkzaam", - shortname: "Nw", - value: dose.p_dose_nw, - unit: "kg/ha", - limit: undefined, - advice: undefined, - }, - { - title: "Fosfaat, totaal", - shortname: "P2O5", - value: dose.p_dose_p, - unit: "kg/ha", - limit: undefined, - advice: undefined, - }, - { - title: "Kalium, totaal", - shortname: "K2O", - value: dose.p_dose_k, - unit: "kg/ha", - limit: undefined, - advice: undefined, - }, - ] - - return cards -} +import { FertilizerApplicationsList } from "./list" +import type { FertilizerApplication, FertilizerOption } from "./types.d" export function FertilizerApplicationCard({ fertilizerApplications, applicationMethodOptions, + fertilizers, fertilizerOptions, - dose, }: { fertilizerApplications: FertilizerApplication[] applicationMethodOptions: { value: ApplicationMethods label: string }[] + fertilizers: Fertilizer[] fertilizerOptions: FertilizerOption[] dose: Dose + className?: string }) { const fetcher = useFetcher() const location = useLocation() const params = useParams() const navigation = useNavigation() const [isDialogOpen, setIsDialogOpen] = useState(false) + const [editedFertilizerApplication, setEditedFertilizerApplication] = + useState() const previousNavigationState = useRef(navigation.state) const b_id_or_b_lu_catalogue = params.b_lu_catalogue || params.b_id @@ -165,12 +52,18 @@ export function FertilizerApplicationCard({ fetcher.submit({ p_app_id }, { method: "DELETE" }) } + const handleEdit = (fertilizerApplication: FertilizerApplication) => () => { + setEditedFertilizerApplication(fertilizerApplication) + setIsDialogOpen(true) + } + useEffect(() => { const wasNotIdle = previousNavigationState.current !== "idle" const isIdle = navigation.state === "idle" if (wasNotIdle && isIdle) { setIsDialogOpen(false) + setEditedFertilizerApplication(undefined) } previousNavigationState.current = navigation.state @@ -178,16 +71,51 @@ export function FertilizerApplicationCard({ const fieldFertilizerFormStore = useFieldFertilizerFormStore() const savedFormValues = - params.b_id_farm && - b_id_or_b_lu_catalogue && - fieldFertilizerFormStore.load(params.b_id_farm, b_id_or_b_lu_catalogue) + params.b_id_farm && b_id_or_b_lu_catalogue + ? fieldFertilizerFormStore.load( + params.b_id_farm, + b_id_or_b_lu_catalogue, + ) + : null + + // See if the saved form was for updating an existing application. + // If so, verify that the user can still edit the application and update the state. + const applicationToEdit = savedFormValues?.p_app_id + ? fertilizerApplications.find( + (app) => app.p_app_id === savedFormValues.p_app_id, + ) + : null useEffect(() => { - if (!isDialogOpen && savedFormValues) { - setIsDialogOpen(true) + if (applicationToEdit && !editedFertilizerApplication) { + setEditedFertilizerApplication(applicationToEdit) + } + if (savedFormValues?.p_app_id && !applicationToEdit) { + fieldFertilizerFormStore.delete( + params.b_id_farm || "", + b_id_or_b_lu_catalogue || "", + ) } - }, [isDialogOpen, savedFormValues]) + }, [ + applicationToEdit, + params.b_id_farm, + b_id_or_b_lu_catalogue, + savedFormValues, + editedFertilizerApplication, + fieldFertilizerFormStore, + ]) - const detailCards = constructCards(dose) + useEffect(() => { + if (savedFormValues && !isDialogOpen) { + if (savedFormValues.p_app_id) { + // Do not open the form if there is a risk it will create a new application + if (applicationToEdit) { + setIsDialogOpen(true) + } + } else { + setIsDialogOpen(true) + } + } + }, [savedFormValues, applicationToEdit, isDialogOpen]) function handleDialogOpenChange(state: boolean) { if (!state && params.b_id_farm && b_id_or_b_lu_catalogue) { @@ -197,34 +125,40 @@ export function FertilizerApplicationCard({ ) } + if (!state) { + setEditedFertilizerApplication(undefined) + } + setIsDialogOpen(state) } return ( - - + +

Bemesting

-

- Voeg bemestingen toe, verwijder ze en bekijk de totale - gift per hectare voor verschillende nutriënten -

- + - Bemesting toevoegen + {editedFertilizerApplication + ? "Bemesting wijzigen" + : "Bemesting toevoegen"} - Voeg een nieuwe bemestingstoepassing toe aan het - perceel. + {editedFertilizerApplication + ? "Wijzig een bemestingtoepassing aan het percel." + : "Voeg een nieuwe bemestingstoepassing toe aan het perceel."}
- -
- {fertilizerApplications.length > 0 ? ( - fertilizerApplications.map((application) => ( -
-
-

- {application.p_name_nl} -

-

- {application.p_app_method - ? applicationMethodOptions.find( - (x) => - x.value === - application.p_app_method, - )?.label - : "Toedieningsmethode niet bekend"} -

-
-
-

- {application.p_app_amount} kg / ha -

-
-
-

- {format( - application.p_app_date, - "yyyy-MM-dd", - )} -

-
-
- -
-
- )) - ) : ( -
-
-

- Je hebt nog geen bemesting ingevuld... -

-

- Voeg een bemesting toe om gegevens zoals, - meststof, hoeveelheid en datum bij te - houden. -

-
-
- )} -
-
- {detailCards.map( - (card: FertilizerApplicationsCardProps) => ( - - ), - )} -
+ +
) diff --git a/fdm-app/app/components/blocks/fertilizer-applications/cards.tsx b/fdm-app/app/components/blocks/fertilizer-applications/cards.tsx deleted file mode 100644 index cd9bbaf48..000000000 --- a/fdm-app/app/components/blocks/fertilizer-applications/cards.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import type { Dose } from "@svenvw/fdm-calculator" -import { Lightbulb, Scale } from "lucide-react" -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "~/components/ui/tooltip" -import { cn } from "~/lib/utils" -import type { FertilizerApplicationsCardProps } from "./types.d" - -function FertilizerApplicationsCard({ - title, - shortname, - value, - unit, - limit, - advice, -}: FertilizerApplicationsCardProps) { - return ( - - - {title} -

{shortname}

-
- -
-
{`${Math.round(value)}`}
-
{unit}
-
-
- - - -
- -

- {`${limit}`} -

-
-
- - {`Gebruiksnorm voor ${title} [${unit}]`} - -
-
- - - -
- -

- {`${advice}`} -

-
-
- - {`Bemestingsadvies voor ${title} [${unit}]`} - -
-
-
-
-
- ) -} - -export function FertilizerApplicationsCards({ dose }: { dose: Dose }) { - const cards = constructCards(dose) - - return ( -
- {cards.map((card: FertilizerApplicationsCardProps) => ( - - ))} -
- ) -} - -function constructCards(dose: Dose) { - // Construct the fertilizer application cards - const cards: FertilizerApplicationsCardProps[] = [ - { - title: "Stikstof, totaal", - shortname: "Ntot", - value: dose.p_dose_n, - unit: "kg/ha", - limit: undefined, - advice: undefined, - }, - { - title: "Stikstof, werkzaam", - shortname: "Nw", - value: dose.p_dose_nw, - unit: "kg/ha", - limit: undefined, - advice: undefined, - }, - { - title: "Fosfaat, totaal", - shortname: "P2O5", - value: dose.p_dose_p, - unit: "kg/ha", - limit: undefined, - advice: undefined, - }, - { - title: "Kalium, totaal", - shortname: "K2O", - value: dose.p_dose_k, - unit: "kg/ha", - limit: undefined, - advice: undefined, - }, - ] - - return cards -} diff --git a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx index 31e3660e8..2f4e939f8 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/form.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/form.tsx @@ -1,11 +1,11 @@ import { zodResolver } from "@hookform/resolvers/zod" +import type { FertilizerApplication } from "@svenvw/fdm-core" import { Plus } from "lucide-react" import type { MouseEvent } from "react" -import { useEffect } from "react" +import { useEffect, useId } from "react" import type { Navigation } from "react-router" import { Form, useNavigate, useSearchParams } from "react-router" import { RemixFormProvider, useRemixForm } from "remix-hook-form" -import type { z } from "zod" import { useFieldFertilizerFormStore } from "@/app/store/field-fertilizer-form" import { Combobox } from "~/components/custom/combobox" import { DatePicker } from "~/components/custom/date-picker" @@ -32,7 +32,11 @@ import { TooltipContent, TooltipTrigger, } from "~/components/ui/tooltip" -import { FormSchema } from "./formschema" +import { + type FieldFertilizerFormValues, + FormSchema, + FormSchemaModify, +} from "./formschema" import type { FertilizerOption } from "./types.d" export function FertilizerApplicationForm({ @@ -41,24 +45,34 @@ export function FertilizerApplicationForm({ navigation, b_id_farm, b_id_or_b_lu_catalogue, + fertilizerApplication, }: { options: FertilizerOption[] action: string navigation: Navigation b_id_farm: string b_id_or_b_lu_catalogue: string + fertilizerApplication: FertilizerApplication }) { const navigate = useNavigate() const [searchParams] = useSearchParams() - - const form = useRemixForm>({ + const formId = useId() + const form = useRemixForm({ mode: "onTouched", - resolver: zodResolver(FormSchema), + resolver: zodResolver( + fertilizerApplication ? FormSchemaModify : FormSchema, + ), defaultValues: { - p_id: undefined, - p_app_method: undefined, - p_app_amount: undefined, - p_app_date: new Date(), + p_app_id: fertilizerApplication?.p_app_ids + ? fertilizerApplication.p_app_ids.join(",") + : fertilizerApplication?.p_app_id, + 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(), + }, + submitConfig: { + method: fertilizerApplication ? "PUT" : "POST", }, }) const p_id = form.watch("p_id") @@ -66,10 +80,13 @@ export function FertilizerApplicationForm({ const isSubmitting = navigation.state === "submitting" useEffect(() => { - if (p_id) { + if ( + p_id && + (!fertilizerApplication || fertilizerApplication.p_id !== p_id) + ) { form.setValue("p_app_method", "") } - }, [p_id, form.setValue]) + }, [p_id, fertilizerApplication, form.setValue]) const fieldFertilizerFormStore = useFieldFertilizerFormStore() @@ -97,6 +114,12 @@ export function FertilizerApplicationForm({ fieldFertilizerFormStore.load, ]) + useEffect(() => { + if (fertilizerApplication) { + form.setValue("p_app_amount", fertilizerApplication.p_app_amount) + } + }, [fertilizerApplication, form.setValue]) + // Change fertilizer selection if the user has added a new fertilizer const new_p_id = searchParams.get("p_id") useEffect(() => { @@ -134,7 +157,7 @@ export function FertilizerApplicationForm({ return (
Opslaan... + ) : fertilizerApplication ? ( + "Opslaan" ) : ( "Voeg toe" )} diff --git a/fdm-app/app/components/blocks/fertilizer-applications/formschema.tsx b/fdm-app/app/components/blocks/fertilizer-applications/formschema.tsx index b75e939d9..5aa24ced7 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/formschema.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/formschema.tsx @@ -23,3 +23,15 @@ export const FormSchema = z.object({ invalid_type_error: "Meststof is ongeldig", }), }) + +export const FormSchemaModify = FormSchema.extend({ + p_app_id: z.string({ + // TODO: Validate against the options that are available + required_error: "Bemesting id is verplicht", + invalid_type_error: "Bemesting id is ongeldig", + }), +}) + +export type FieldFertilizerFormValues = z.infer & { + p_app_id?: string | undefined +} diff --git a/fdm-app/app/components/blocks/fertilizer-applications/list.tsx b/fdm-app/app/components/blocks/fertilizer-applications/list.tsx index 593d301d9..8c5d54bda 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/list.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/list.tsx @@ -1,102 +1,180 @@ +import type { Fertilizer, FertilizerApplication } from "@svenvw/fdm-core" import type { ApplicationMethods } from "@svenvw/fdm-data" import { format } from "date-fns" +import { nl } from "date-fns/locale" +import { Circle, Diamond, Square, Trash, Triangle } from "lucide-react" import { useFetcher } from "react-router" import { Button } from "~/components/ui/button" +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "~/components/ui/empty" +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemGroup, + ItemSeparator, + ItemTitle, +} from "~/components/ui/item" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/ui/tooltip" import { LoadingSpinner } from "../../custom/loadingspinner" -import type { FertilizerApplication } from "./types.d" export function FertilizerApplicationsList({ fertilizerApplications, applicationMethodOptions, + fertilizers, + handleDelete, + handleEdit, }: { fertilizerApplications: FertilizerApplication[] applicationMethodOptions: { value: ApplicationMethods label: string }[] + fertilizers: Fertilizer[] + handleDelete: (p_app_id: string | string[]) => void + handleEdit: (fertilizerApplication: FertilizerApplication) => () => void }) { const fetcher = useFetcher() - const handleDelete = (p_app_id: string | string[]) => { - if (fetcher.state === "submitting") return - - fetcher.submit({ p_app_id }, { method: "DELETE" }) - } return (
- {/*
Meststoffen
*/} -
- {fertilizerApplications.length > 0 ? ( - fertilizerApplications.map((application) => ( -
-
-

- {application.p_name_nl} -

-

- {application.p_app_method - ? applicationMethodOptions.find( - (x) => - x.value === - application.p_app_method, - )?.label - : "Toedieningsmethode niet bekend"} -

-
-
-

- {application.p_app_amount} kg / ha -

-
-
-

- {format( - application.p_app_date, - "yyyy-MM-dd", - )} -

-
-
- + {fertilizerApplications.length > 0 ? ( + + {fertilizerApplications.map((application) => { + const fertilizer = fertilizers.find( + (f) => f.p_id === application.p_id, + ) + if (!fertilizer) { + return null + } + + return ( +
+ + + + + + {fertilizer.p_type === + "manure" ? ( + + ) : fertilizer.p_type === + "mineral" ? ( + + ) : fertilizer.p_type === + "compost" ? ( + + ) : ( + + )} + + + + {format( + application.p_app_date, + "PP", + { + locale: nl, + }, + )} + + + +

+ {application.p_app_amount} kg / + ha +

+

+ {application.p_app_method + ? applicationMethodOptions.find( + (x) => + x.value === + application.p_app_method, + )?.label + : "Toedieningsmethode niet bekend"} +

+
+
+ + + + + + + +

Verwijder

+
+
+
+
+
-
- )) - ) : ( -
-
-

- Je hebt nog geen bemesting ingevuld... -

-

- Voeg een bemesting toe om gegevens zoals, - meststof, hoeveelheid en datum bij te houden. -

-
-
- )} -
+ ) + })} + + + ) : ( + + + + Je hebt nog geen bemesting ingevuld... + + + Voeg een bemesting toe om gegevens zoals, meststof, + hoeveelheid en datum bij te houden. + + + + )}
) } diff --git a/fdm-app/app/components/blocks/fertilizer-applications/metrics.tsx b/fdm-app/app/components/blocks/fertilizer-applications/metrics.tsx new file mode 100644 index 000000000..6c81c6aa7 --- /dev/null +++ b/fdm-app/app/components/blocks/fertilizer-applications/metrics.tsx @@ -0,0 +1,741 @@ +import type { + Dose, + GebruiksnormResult, + NitrogenBalanceNumeric, + NormFilling, + NutrientAdvice, +} from "@svenvw/fdm-calculator" +import { Suspense } from "react" +import { Await, NavLink } from "react-router-dom" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { + Item, + ItemContent, + ItemDescription, + ItemGroup, + ItemSeparator, + ItemTitle, +} from "~/components/ui/item" +import { Progress } from "~/components/ui/progress" +import { Skeleton } from "~/components/ui/skeleton" +import { Spinner } from "~/components/ui/spinner" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip" + +interface FertilizerApplicationMetricsData { + norms: Promise<{ + value: { + manure: GebruiksnormResult + phosphate: GebruiksnormResult + nitrogen: GebruiksnormResult + } + filling: { + manure: NormFilling + phosphate: NormFilling + nitrogen: NormFilling + } + }> + nitrogenBalance: Promise | undefined + nutrientAdvice: NutrientAdvice + dose: Dose + b_id: string + b_id_farm: string + calendar: string +} + +interface FertilizerApplicationMetricsCardProps { + fertilizerApplicationMetricsData: FertilizerApplicationMetricsData + isSubmitting: boolean +} + +export function FertilizerApplicationMetricsCard({ + fertilizerApplicationMetricsData, + isSubmitting, +}: FertilizerApplicationMetricsCardProps) { + const getNormsProgressColor = (current: number, total: number) => { + const percentage = (current / total) * 100 + if (percentage > 100) return "red-500" + return "green-500" + } + + const getAdviceProgressColor = (current: number, total: number) => { + const percentage = (current / total) * 100 + if (percentage < 80) return "orange-500" + if (percentage >= 80 && percentage <= 105) return "green-500" + if (percentage > 105) return "orange-500" + return "gray-500" // Default or error color + } + + const { + norms, + nitrogenBalance, + nutrientAdvice, + dose, + b_id, + b_id_farm, + calendar, + } = fertilizerApplicationMetricsData + + return ( + + + + Bemestingsdashboard + + + Krijg inzicht in de effecten van de bemesting. + + + +
+ + + + + + + Gebruiksnormen + + + + + {isSubmitting ? ( + + ) : ( + }> + + Helaas, er is wat misgegaan + met de berekening +
+ } + resolve={norms} + > + {(resolvedNorms) => ( +
+
+ + +

+ Stikstof +

+
+ +

+ Werkzame + stikstof + volgens + forfaitaire + gehalten +

+
+
+ + {Math.round( + resolvedNorms + .filling + .nitrogen, + )}{" "} + /{" "} + {Math.round( + resolvedNorms + .value + .nitrogen, + )}{" "} + kg N + +
+ + +
+ + +

+ Fosfaat +

+
+ +

+ Fosfaataanvoer + incl. + mogelijke + stimuleringsregeling +

+
+
+ + {Math.round( + resolvedNorms + .filling + .phosphate, + )}{" "} + /{" "} + {Math.round( + resolvedNorms + .value + .phosphate, + )}{" "} + kg P₂O₅ + +
+ + +
+ + +

+ Dierlijke + mest +

+
+ +

+ Totaal + stikstof via + dierlijke + mest +

+
+
+ + {Math.round( + resolvedNorms + .filling + .manure, + )}{" "} + /{" "} + {Math.round( + resolvedNorms + .value + .manure, + )}{" "} + kg N + +
+ +
+ )} + + + )} + + + + + + + + + + Stikstofbalans + + + + + {isSubmitting ? ( + + ) : ( + } + > + + Helaas, er is wat misgegaan + met de berekening +
+ } + resolve={nitrogenBalance} + > + {(resolvedNitrogenBalance) => { + const task = + resolvedNitrogenBalance + .balance.target - + resolvedNitrogenBalance + .balance.balance + return ( +
+ {/* Simplified Flow (Top Section) */} +
+ + +

+ Aanvoer +

+
+ +

+ Totaal + stikstof + via + bemesting, + depositie, + mineralisatie + en + fixatie +

+
+
+ + + {Math.round( + resolvedNitrogenBalance + .balance + .supply + .total, + )}{" "} + kg N + +
+
+ + +

+ Afvoer +

+
+ +

+ Totaal + stikstof + via + oogst en + gewasresten +

+
+
+ + {Math.round( + resolvedNitrogenBalance + .balance + .removal + .total, + )}{" "} + kg N + +
+
+ + +

+ Emissie +

+
+ +

+ Totaal + stikstof + via + gasvormige + verliezen + (NH3) +

+
+
+ + {Math.round( + resolvedNitrogenBalance + .balance + .emission + .total, + )}{" "} + kg N + +
+ {" "} + {/* Separator for clarity */} + {/* Prominent Result (Bottom Section) */} +
+

+ Balans +

+ + {Math.round( + resolvedNitrogenBalance + .balance + .balance, + )}{" "} + kg N + +
+
+

+ Streefwaarde +

+ + {Math.round( + resolvedNitrogenBalance + .balance + .target, + )}{" "} + kg N + +
+ {" "} +
+ + +

+ {task < + 0 + ? "Opgave" + : "Ruimte"} +

+
+ +

+ {task < + 0 + ? "Hoeveelheid totaal stikstof die verminderd moet worden om het doel te halen" + : "Hoeveelheid totaal stikstof die nog over waarbij het doel gehaald kan worden"} +

+
+
+ + {Math.round( + task, + )}{" "} + kg N + +
+
+ ) + }} + + + )} + + + + + + + + + + Bemestingsadvies + + + + + {isSubmitting ? ( + + ) : ( + } + > + + Helaas, er is wat misgegaan + met de berekening + + } + resolve={nutrientAdvice} + > + {(resolvedNutrientAdvice) => ( +
+
+ + +

+ Stikstof +

+
+ +

+ Werkzame + stikstof +

+
+
+ + {Math.round( + dose.p_dose_n, + )}{" "} + /{" "} + {Math.round( + resolvedNutrientAdvice.d_n_req, + )}{" "} + kg N + +
+ + +
+

+ Fosfaat +

+ + {Math.round( + dose.p_dose_p, + )}{" "} + /{" "} + {Math.round( + resolvedNutrientAdvice.d_p_req, + )}{" "} + kg P₂O₅ + +
+ + +
+

+ Kalium +

+ + {Math.round( + dose.p_dose_k, + )}{" "} + /{" "} + {Math.round( + resolvedNutrientAdvice.d_k_req, + )}{" "} + kg K₂O + +
+ +
+ )} +
+
+ )} +
+
+
+ + + + ) +} + +const NormsSkeleton = () => ( +
+
+

Stikstof

+ + {} kg N + +
+ + +
+

Fosfaat

+ + {} kg P₂O₅ + +
+ + +
+

Dierlijke mest

+ + {} kg N + +
+ +
+) +const NitrogenBalanceSkeleton = () => ( +
+
+

Aanvoer

+ + + +
+
+

Afvoer

+ + + +
+
+

Emissie

+ + + +
+ +
+

Balans

+ + + +
+
+

Streefwaarde

+ + + +
+ {" "} +
+

+ Opgave +

+ + + +
+
+) + +const NutrientAdviceSkeleton = () => ( +
+
+

Stikstof

+ + {} kg N + +
+ +
+

Fosfaat

+ + {} kg P₂O₅ + +
+ + +
+

Kalium

+ + {} + kg K₂O + +
+ +
+) diff --git a/fdm-app/app/components/blocks/fertilizer/columns.tsx b/fdm-app/app/components/blocks/fertilizer/columns.tsx index 986be7fc9..4d0194216 100644 --- a/fdm-app/app/components/blocks/fertilizer/columns.tsx +++ b/fdm-app/app/components/blocks/fertilizer/columns.tsx @@ -16,9 +16,9 @@ export type Fertilizer = { p_n_rt?: number | null p_p_rt?: number | null p_k_rt?: number | null - p_type_manure?: boolean - p_type_compost?: boolean - p_type_mineral?: boolean + p_type_rvo?: string | null + p_type_rvo_label?: string | null + p_type?: "manure" | "compost" | "mineral" | null p_eoc?: number | null p_source?: string p_n_wc?: number | null @@ -62,36 +62,57 @@ export const columns: ColumnDef[] = [ }, }, { - accessorKey: "Type", + accessorKey: "p_type_rvo", + header: ({ column }) => { + return ( + + ) + }, cell: ({ row }) => { const fertilizer = row.original + if (!fertilizer.p_type_rvo) { + return null + } + const p_type = fertilizer.p_type + const rawLabel = fertilizer.p_type_rvo_label?.trim() ?? "" + const displayLabel = rawLabel || fertilizer.p_type_rvo || "Onbekend" + const MAX_LABEL_LEN = 48 + const isTruncated = displayLabel.length > MAX_LABEL_LEN + const truncatedLabel = isTruncated + ? `${displayLabel.substring(0, MAX_LABEL_LEN)}...` + : displayLabel + + const badge = ( + +

{truncatedLabel}

+
+ ) return ( - {fertilizer.p_type_manure ? ( - - Mest - - ) : null} - {fertilizer.p_type_compost ? ( - - Compost - - ) : null} - {fertilizer.p_type_mineral ? ( - - Kunstmest - - ) : null} + {isTruncated ? ( + + + {badge} + +

{displayLabel}

+
+
+
+ ) : ( + badge + )}
) }, diff --git a/fdm-app/app/components/blocks/fertilizer/form.tsx b/fdm-app/app/components/blocks/fertilizer/form.tsx index dad69679d..3b20583c0 100644 --- a/fdm-app/app/components/blocks/fertilizer/form.tsx +++ b/fdm-app/app/components/blocks/fertilizer/form.tsx @@ -120,7 +120,7 @@ export function FertilizerForm({ > - + @@ -130,7 +130,7 @@ export function FertilizerForm({ key={option.value} value={option.value} > - {option.label} + {`${option.label} (${option.value})`} ) })} diff --git a/fdm-app/app/components/blocks/fertilizer/formschema.tsx b/fdm-app/app/components/blocks/fertilizer/formschema.tsx index 232678aed..d25a3f501 100644 --- a/fdm-app/app/components/blocks/fertilizer/formschema.tsx +++ b/fdm-app/app/components/blocks/fertilizer/formschema.tsx @@ -10,9 +10,12 @@ export const FormSchema = z .min(1, { message: "Geef een naam op voor deze meststof" }), p_name_en: z.string().optional(), p_description: z.string().optional(), - p_type: z.enum(["manure", "mineral", "compost"], { - required_error: "Kies het type meststof", - }), + p_type_rvo: z + .string({ + required_error: "RVO mestcode is verplicht", + invalid_type_error: "Ongeldige waarde", + }) + .min(1, { message: "RVO mestcode mag niet leeg zijn" }), p_dm: z.preprocess( (val) => (val === "" || val === null ? undefined : val), z.coerce diff --git a/fdm-app/app/components/blocks/fertilizer/new-fertilizer-page.tsx b/fdm-app/app/components/blocks/fertilizer/new-fertilizer-page.tsx index 217cfe051..1fbc153bd 100644 --- a/fdm-app/app/components/blocks/fertilizer/new-fertilizer-page.tsx +++ b/fdm-app/app/components/blocks/fertilizer/new-fertilizer-page.tsx @@ -31,7 +31,7 @@ export function FarmNewFertilizerBlock({ resolver: zodResolver(FormSchema), defaultValues: { p_name_nl: "", - p_type: fertilizer.p_type, + p_type_rvo: fertilizer.p_type_rvo, p_dm: fertilizer.p_dm, p_density: fertilizer.p_density, p_om: fertilizer.p_om, diff --git a/fdm-app/app/components/blocks/header/fertilizer.tsx b/fdm-app/app/components/blocks/header/fertilizer.tsx index 84f27b537..2ab04bbb4 100644 --- a/fdm-app/app/components/blocks/header/fertilizer.tsx +++ b/fdm-app/app/components/blocks/header/fertilizer.tsx @@ -28,8 +28,8 @@ export function HeaderFertilizer({ <> - - Meststof + + Meststoffen {fertilizerOptions.length > 0 ? ( diff --git a/fdm-app/app/components/blocks/header/norms.tsx b/fdm-app/app/components/blocks/header/norms.tsx index 6d9661a4c..def669956 100644 --- a/fdm-app/app/components/blocks/header/norms.tsx +++ b/fdm-app/app/components/blocks/header/norms.tsx @@ -1,11 +1,27 @@ +import { ChevronDown } from "lucide-react" +import { NavLink } from "react-router" import { useCalendarStore } from "@/app/store/calendar" import { BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, } from "~/components/ui/breadcrumb" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" -export function HeaderNorms({ b_id_farm }: { b_id_farm: string }) { +export function HeaderNorms({ + b_id_farm, + b_id, + fieldOptions, +}: { + b_id_farm: string + b_id?: string | undefined + fieldOptions?: HeaderFieldOption[] +}) { const calendar = useCalendarStore((state) => state.calendar) return ( @@ -16,6 +32,42 @@ export function HeaderNorms({ b_id_farm }: { b_id_farm: string }) { Gebruiksruimte + {b_id && fieldOptions ? ( + <> + + + + + {b_id && fieldOptions + ? (fieldOptions.find( + (option) => option.b_id === b_id, + )?.b_name ?? "Unknown field") + : "Kies een perceel"} + + + + {fieldOptions.map((option) => ( + + + {option.b_name} + + + ))} + + + + + ) : null} ) } + +type HeaderFieldOption = { + b_id: string + b_name: string | undefined | null +} diff --git a/fdm-app/app/components/blocks/mijnpercelen/form-upload.tsx b/fdm-app/app/components/blocks/mijnpercelen/form-upload.tsx index 124993fc4..2264cc1be 100644 --- a/fdm-app/app/components/blocks/mijnpercelen/form-upload.tsx +++ b/fdm-app/app/components/blocks/mijnpercelen/form-upload.tsx @@ -231,6 +231,17 @@ export function MijnPercelenUploadForm({ ) form.setValue("shapefile", updatedFiles, { shouldValidate: true }) + + const fileInput = document.getElementById( + "file-upload", + ) as HTMLInputElement | null + if (fileInput) { + const container = new DataTransfer() + updatedFiles.forEach((f) => { + container.items.add(f) + }) + fileInput.files = container.files + } await handleFilesSet(updatedFiles) e.dataTransfer.clearData() } @@ -361,13 +372,56 @@ export function MijnPercelenUploadForm({ name, onBlur, onChange, - disabled, ref, }, }) => (
Shapefile
-
+ { + await handleFileChange( + event, + onChange, + ) + }} + ref={ref} + type="file" + placeholder="" + className="hidden" + multiple + required + id="file-upload" + /> +
- +
diff --git a/fdm-app/app/components/blocks/norms/farm-norms.tsx b/fdm-app/app/components/blocks/norms/farm-norms.tsx index 9f12fd15e..6b486526a 100644 --- a/fdm-app/app/components/blocks/norms/farm-norms.tsx +++ b/fdm-app/app/components/blocks/norms/farm-norms.tsx @@ -1,101 +1,66 @@ +import type { + AggregatedNormFillingsToFarmLevel, + AggregatedNormsToFarmLevel, +} from "@svenvw/fdm-calculator" import { AlertTriangle } from "lucide-react" -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "~/components/ui/tooltip" +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert" +import { NormCard } from "./norm-card" interface FarmNormsProps { - farmNorms: { - manure: number - phosphate: number - nitrogen: number - } + farmNorms: AggregatedNormsToFarmLevel + farmFillings: AggregatedNormFillingsToFarmLevel | undefined hasFieldNormErrors: boolean fieldErrorMessages: string[] } export function FarmNorms({ farmNorms, + farmFillings, hasFieldNormErrors, fieldErrorMessages, }: FarmNormsProps) { return ( -
-

- {hasFieldNormErrors && ( - - - - - - -

- Voor sommige percelen zijn de gebruiksnormen - niet volledig berekend: -

-
    - {fieldErrorMessages.map((msg) => ( -
  • {msg}
  • - ))} -
-
-
-
- )} - Bedrijfsniveau -

-
- - - - Stikstof - - - -
- {farmNorms.nitrogen} kg N -
- {/*

*/} -
-
- - - - Fosfaat - - - -
- {farmNorms.phosphate} kg P2O5 -
- {/*

*/} -
-
- - - - Stikstof uit dierlijke mest - - - -
- {farmNorms.manure} kg N -
- {/*

*/} -
-
+
+ {hasFieldNormErrors && ( + + + Fouten bij perceelsnormen + +

+ Voor één of meerdere percelen konden de + gebruiksnormen niet volledig worden berekend. De + totalen op bedrijfsniveau kunnen hierdoor afwijken. +

+
    + {fieldErrorMessages.map((msg) => ( +
  • {msg}
  • + ))} +
+
+
+ )} +
+ + +
) diff --git a/fdm-app/app/components/blocks/norms/field-norms.tsx b/fdm-app/app/components/blocks/norms/field-norms.tsx index 1ec883cd0..1cae37aef 100644 --- a/fdm-app/app/components/blocks/norms/field-norms.tsx +++ b/fdm-app/app/components/blocks/norms/field-norms.tsx @@ -1,3 +1,9 @@ +import type { + NormFilling as GebruiksnormFillingResult, + GebruiksnormResult, +} from "@svenvw/fdm-calculator" +import { NavLink } from "react-router-dom" +import { FieldFilterToggle } from "~/components/custom/field-filter-toggle" import { Card, CardContent, @@ -5,20 +11,19 @@ import { CardHeader, CardTitle, } from "~/components/ui/card" -import { FieldFilterToggle } from "../../custom/field-filter-toggle" - -interface Norm { - normValue: number - normSource: string -} export interface FieldNorm { b_id: string b_area: number norms?: { - manure: Norm - phosphate: Norm - nitrogen: Norm + manure: GebruiksnormResult + phosphate: GebruiksnormResult + nitrogen: GebruiksnormResult + } + normsFilling?: { + manure: GebruiksnormFillingResult + phosphate: GebruiksnormFillingResult + nitrogen: GebruiksnormFillingResult } errorMessage?: string } @@ -31,6 +36,75 @@ interface FieldNormsProps { }[] } +const getProgressColorClass = (percentage: number) => { + if (percentage > 100) return "bg-orange-500" + return "bg-green-500" +} + +interface ProgressBarProps { + value: number +} + +const ProgressBar = ({ value }: ProgressBarProps) => ( +
+
+
+) + +interface NormItemProps { + fieldId: string + normName: "nitrogen" | "phosphate" | "manure" + title: string + unit: string + norm: GebruiksnormResult | undefined + filling: GebruiksnormFillingResult | undefined +} + +function NormItem({ + fieldId, + normName, + title, + unit, + norm, + filling, +}: NormItemProps) { + if (!norm) return null + + const normValue = norm.normValue || 0 + const normSource = norm.normSource || "" + const fillingValue = filling?.normFilling || 0 + const percentage = normValue > 0 ? (fillingValue / normValue) * 100 : 0 + + return ( +
+
+
+

{title}

+

+ {normSource} +

+
+
+

+ {normValue.toFixed(0)} kg +

+
+
+ {filling !== undefined && ( +
+

+ {fillingValue.toFixed(0)} kg gebruikt +

+ +
+ )} +
+ ) +} + export function FieldNorms({ fieldNorms, fieldOptions }: FieldNormsProps) { const getFieldName = (b_id: string) => { return ( @@ -41,115 +115,74 @@ export function FieldNorms({ fieldNorms, fieldOptions }: FieldNormsProps) { return (
-
-

Perceelsniveau

+
+

Perceelsniveau

-
+ {fieldNorms.length === 0 && ( +
+

Geen percelen gevonden die voldoen aan de criteria.

+
+ )} + +
{fieldNorms.map((field) => ( - - -
- + + + + {getFieldName(field.b_id)} - - {`${field.b_area} ha`} - -
-
- - {field.errorMessage ? ( -
-

- Helaas kunnen we nog geen gebruiksnormen - uitrekenen voor dit perceel. -

-

- {field.errorMessage} -

-
- ) : ( - <> - {/* Stikstofgebruiksnorm */} -
-
-

- Stikstof -

-

- { - field.norms?.nitrogen - .normSource - } -

-
-
-

- { - field.norms?.nitrogen - .normValue - }{" "} - - kg N / ha - -

-
+ {`${field.b_area.toFixed( + 2, + )} ha`} + + + {field.errorMessage ? ( +
+

+ Kon gebruiksnormen niet berekenen +

+

+ {field.errorMessage} +

- - {/* Fosfaatgebruiksnorm */} -
-
-

- Fosfaat -

-

- { - field.norms?.phosphate - .normSource - } -

-
-
-

- { - field.norms?.phosphate - .normValue - }{" "} - - kg P2O5 /ha - -

-
-
- - {/* Dierlijke mest gebruiksnorm */} -
-
-

- Stikstof uit dierlijke mest -

-

- {field.norms?.manure.normSource} -

-
-
-

- {field.norms?.manure.normValue}{" "} - - kg N / ha - -

-
+ ) : ( +
+ + +
- - )} - - + )} + + + ))}
diff --git a/fdm-app/app/components/blocks/norms/norm-card.tsx b/fdm-app/app/components/blocks/norms/norm-card.tsx new file mode 100644 index 000000000..616de0311 --- /dev/null +++ b/fdm-app/app/components/blocks/norms/norm-card.tsx @@ -0,0 +1,74 @@ +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card" + +const getProgressColorClass = ({ percentage, type }: ProgressBarProps) => { + if (percentage > 100 && type === "farm") return "bg-red-500" + if (percentage > 100 && type === "field") return "bg-orange-500" + return "bg-green-500" +} + +interface ProgressBarProps { + percentage: number + type: "farm" | "field" +} + +const ProgressBar = ({ percentage, type }: ProgressBarProps) => ( +
+
+
+) + +interface NormCardProps { + title: string + type: "farm" | "field" + norm: number + filling: number | undefined + unit: string +} + +export function NormCard({ title, type, norm, filling, unit }: NormCardProps) { + const fillingpercentage = filling || 0 + const percentage = norm > 0 ? (fillingpercentage / norm) * 100 : 0 + + return ( + + + {title} + + +
+ {filling !== undefined && ( +
+

+ Opvulling +

+
+ {fillingpercentage.toFixed(0)} +
+ {/*

+ {unit} +

*/} +
+ )} +
+

Ruimte

+
+ {norm.toFixed(0)} +
+

{unit}

+
+
+ {filling !== undefined && ( +
+ +

+ {percentage.toFixed(0)}% +

+
+ )} +
+
+ ) +} diff --git a/fdm-app/app/components/blocks/nutrient-advice/cards.tsx b/fdm-app/app/components/blocks/nutrient-advice/cards.tsx index 1916d6c4e..ee7b26815 100644 --- a/fdm-app/app/components/blocks/nutrient-advice/cards.tsx +++ b/fdm-app/app/components/blocks/nutrient-advice/cards.tsx @@ -5,7 +5,6 @@ import { nl } from "date-fns/locale" import { ChevronDown, ChevronUp, TriangleAlert } from "lucide-react" import { useState } from "react" import { NavLink } from "react-router" -import { cn } from "@/app/lib/utils" import { Button } from "~/components/ui/button" import { Card, @@ -59,7 +58,7 @@ export function NutrientCard({ const [isExpanded, setIsExpanded] = useState(false) const doseTotal = (doses.dose[description.doseParameter] as number | undefined) ?? 0 - const percentage = advice > 0 ? (doseTotal / advice) * 100 : 0 + const percentage = advice > 0 ? (doseTotal / advice) * 100 : 100 const numberOfApplicationsForNutrient = doses.applications.filter( (x) => (x[description.doseParameter as keyof Dose] as number) > 0, ).length @@ -81,30 +80,37 @@ export function NutrientCard({
- {advice.toLocaleString()} + {advice > 1 + ? Math.round(advice).toLocaleString() + : advice > 0 + ? advice.toPrecision(2).toLocaleString() + : 0}
{description.unit}
- {advice > 0 && ( -
-
- Bemestingsniveau - {percentage.toFixed(0)}% -
- 100 && description.symbol === "EOC" - ? "[&>div]:bg-green-500 h-3" - : percentage > 100 - ? "[&>div]:bg-orange-500 h-3" - : "h-3", - )} - /> + +
+
+ Bemestingsniveau + + {advice > 0 ? `${Math.round(percentage)}%` : null} +
- )} + 100 || advice === 0) && + description.symbol === "EOC" + ? "green-500" + : percentage > 100 + ? "orange-500" + : undefined + } + className="h-3" + /> +
{fertilizerApplications.length > 0 && numberOfApplicationsForNutrient > 0 ? ( @@ -171,7 +177,15 @@ export function NutrientCard({

- {dose.toFixed(0)}{" "} + {dose > 1 + ? Math.round( + dose, + ).toLocaleString() + : dose > 0 + ? dose + .toPrecision(2) + .toLocaleString() + : 0}{" "} {description.unit}

diff --git a/fdm-app/app/components/blocks/organic-certification/schema.ts b/fdm-app/app/components/blocks/organic-certification/schema.ts new file mode 100644 index 000000000..669f5a2d0 --- /dev/null +++ b/fdm-app/app/components/blocks/organic-certification/schema.ts @@ -0,0 +1,72 @@ +import { z } from "zod" + +/** + * Regular expression for validating EU TRACES document numbers for Organic Operator Certificates. + * Examples: NL-BIO-01.528-0002967.2025.001, NL-BIO-01.528-0005471.2025.001 + * + * NOTE: This is duplicated from fdm-core/src/organic.ts because fdm-core is not fully client-side safe. + */ +const TRACES_REGEX = /^NL-BIO-\d{2}\.\d{3}-\d{7}\.\d{4}\.\d{3}$/ + +/** + * Regular expression for validating SKAL numbers. + * Examples: 026281, 024295 + * + * NOTE: This is duplicated from fdm-core/src/organic.ts because fdm-core is not fully client-side safe. + */ +const SKAL_REGEX = /^\d{6}$/ + +// Client-side safe validation functions +function isValidTracesNumber(tracesNumber: string): boolean { + return TRACES_REGEX.test(tracesNumber) +} + +function isValidSkalNumber(skalNumber: string): boolean { + return SKAL_REGEX.test(skalNumber) +} + +export const formSchema = z + .object({ + b_organic_traces: z + .string() + .trim() + .optional() + .refine((val) => !val || isValidTracesNumber(val), { + message: "Ongeldig TRACES-nummer", + }), + b_organic_skal: z + .string() + .trim() + .optional() + .refine((val) => !val || isValidSkalNumber(val), { + message: "Ongeldig SKAL-nummer", + }), + b_organic_issued: z.coerce.date({ + required_error: "Startdatum is verplicht", + invalid_type_error: "Ongeldige datum", + }), + b_organic_expires: z.coerce + .date({ + invalid_type_error: "Ongeldige datum", + }) + .optional(), + }) + .refine( + (data) => { + if (data.b_organic_issued && data.b_organic_expires) { + return ( + data.b_organic_issued.getTime() < + data.b_organic_expires.getTime() + ) + } + return true + }, + { + message: "Startdatum kan niet na einddatum liggen", + path: ["b_organic_issued"], + }, + ) + .refine((data) => !!(data.b_organic_traces || data.b_organic_skal), { + message: "Vul een TRACES- of SKAL-nummer in", + path: ["b_organic_traces"], + }) diff --git a/fdm-app/app/components/blocks/soil/form-upload.tsx b/fdm-app/app/components/blocks/soil/form-upload.tsx index 2efbc980a..fc1f32374 100644 --- a/fdm-app/app/components/blocks/soil/form-upload.tsx +++ b/fdm-app/app/components/blocks/soil/form-upload.tsx @@ -82,17 +82,26 @@ export function SoilAnalysisUploadForm() { } } - const handleDragOver = (e: React.DragEvent) => { + const handleDragOver = (e: React.DragEvent) => { e.preventDefault() // Prevent default to allow drop } - const handleDrop = (e: React.DragEvent) => { + const handleDrop = (e: React.DragEvent) => { e.preventDefault() // Prevent default file opening in browser if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { const file = e.dataTransfer.files[0] form.setValue("soilAnalysisFile", file, { shouldValidate: true }) setFileName(file.name) setUploadStatus("idle") // Reset status when a new file is dropped + + const fileInput = document.getElementById( + "file-upload", + ) as HTMLInputElement | null + if (fileInput) { + const container = new DataTransfer() + container.items.add(file) + fileInput.files = container.files + } e.dataTransfer.clearData() } } @@ -131,117 +140,107 @@ export function SoilAnalysisUploadForm() { }) => (

Bodemanalyse
-
-
{ + onChange( + event.target + .files?.[0], + ) + handleFileChange( + event, + ) + }} + ref={ref} + type="file" + placeholder="" + className="hidden" + accept=".pdf" + multiple={false} + required={true} + disabled={disabled} + id="file-upload" + /> +
+ diff --git a/fdm-app/app/components/ui/empty.tsx b/fdm-app/app/components/ui/empty.tsx new file mode 100644 index 000000000..66952242b --- /dev/null +++ b/fdm-app/app/components/ui/empty.tsx @@ -0,0 +1,104 @@ +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "~/lib/utils" + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const emptyMediaVariants = cva( + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4", + className, + )} + {...props} + /> + ) +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +} diff --git a/fdm-app/app/components/ui/field.tsx b/fdm-app/app/components/ui/field.tsx new file mode 100644 index 000000000..777c4feed --- /dev/null +++ b/fdm-app/app/components/ui/field.tsx @@ -0,0 +1,241 @@ +import { cva, type VariantProps } from "class-variance-authority" +import { useMemo } from "react" +import { Label } from "~/components/ui/label" +import { Separator } from "~/components/ui/separator" +import { cn } from "~/lib/utils" + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className, + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-slot=field-group]]:gap-4", + className, + )} + {...props} + /> + ) +} + +const fieldVariants = cva( + "group/field data-[invalid=true]:text-destructive flex w-full gap-3", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start", + ], + responsive: [ + "@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +