From 53197a0bd7909e6fba7147046a1b4e40915574e8 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 09:52:49 +0100 Subject: [PATCH 01/23] fix: support uploading for more than 10 pdfs --- fdm-app/app/integrations/nmi.ts | 182 +++++++++++------- .../farm.$b_id_farm.soil-analysis.bulk.tsx | 3 +- ...b_id_farm.$calendar.soil-analysis.bulk.tsx | 5 +- 3 files changed, 115 insertions(+), 75 deletions(-) diff --git a/fdm-app/app/integrations/nmi.ts b/fdm-app/app/integrations/nmi.ts index fd9bbca34..77404a8e2 100644 --- a/fdm-app/app/integrations/nmi.ts +++ b/fdm-app/app/integrations/nmi.ts @@ -267,93 +267,133 @@ export async function extractBulkSoilAnalyses(formData: FormData) { await validatePdfMagicBytes(file) } - const responseApi = await fetch("https://api.nmi-agro.nl/soilreader", { - method: "POST", - headers: { - Authorization: `Bearer ${nmiApiKey}`, - }, - body: formData, - }) + const BATCH_SIZE = 10 + const MAX_BATCH_BYTES = 20 * 1024 * 1024 // 20MB + const allFields: any[] = [] - if (!responseApi.ok) { - const text = await responseApi.text() - console.error( - `NMI API Error: ${responseApi.status} ${responseApi.statusText}`, - text, - ) - throw new Error( - `Request to NMI API failed: ${responseApi.status} ${responseApi.statusText}`, - ) + // Group files into batches based on count and total size + let currentBatchFiles: File[] = [] + let currentBatchSize = 0 + const batches: File[][] = [] + + for (const file of files) { + if ( + currentBatchFiles.length >= BATCH_SIZE || + (currentBatchFiles.length > 0 && + currentBatchSize + file.size > MAX_BATCH_BYTES) + ) { + batches.push(currentBatchFiles) + currentBatchFiles = [] + currentBatchSize = 0 + } + currentBatchFiles.push(file) + currentBatchSize += file.size + } + if (currentBatchFiles.length > 0) { + batches.push(currentBatchFiles) } - const text = await responseApi.text() - try { - const result = JSON.parse(text) - const response = result?.data + // Process each batch + for (let i = 0; i < batches.length; i++) { + const batchFiles = batches[i] + const batchFormData = new FormData() + for (const file of batchFiles) { + batchFormData.append("soilAnalysisFile", file) + } + + const responseApi = await fetch("https://api.nmi-agro.nl/soilreader", { + method: "POST", + headers: { + Authorization: `Bearer ${nmiApiKey}`, + }, + body: batchFormData, + }) - if (!response?.fields || !Array.isArray(response.fields)) { + if (!responseApi.ok) { + const text = await responseApi.text() console.error( - "Invalid NMI API response structure:", + `NMI API Error (Batch ${i + 1}/${batches.length}): ${responseApi.status} ${responseApi.statusText}`, + text, + ) + + if (responseApi.status === 413) { + throw new Error( + `invalid: Groep ${i + 1} van de bestanden is te groot voor de NMI API. Verklein de PDF's of upload ze in nog kleinere groepen.`, + ) + } + + throw new Error( + `Request to NMI API failed: ${responseApi.status} ${responseApi.statusText}`, + ) + } + + const result = await responseApi.json() + const responseData = result?.data + + if (!responseData?.fields || !Array.isArray(responseData.fields)) { + console.error( + `Invalid NMI API response structure in batch ${i + 1}:`, JSON.stringify(result, null, 2), ) - throw new Error("Invalid API response: no fields found") + throw new Error( + `Invalid API response in batch ${i + 1}: no fields found`, + ) } - return response.fields.map((field: any, index: number) => { - const soilAnalysis: { [key: string]: any } = { - id: crypto.randomUUID(), // Used for UI matching - filename: field.filename || `Analyse ${index + 1}`, - } + allFields.push(...responseData.fields) + } - // Safely map known soil parameters (starting with a_) - for (const key of Object.keys(field).filter((key) => - key.startsWith("a_"), - )) { - soilAnalysis[key] = field[key] - } + return allFields.map((field: any, index: number) => { + const soilAnalysis: { [key: string]: any } = { + id: crypto.randomUUID(), // Used for UI matching + filename: field.filename || `Analyse ${index + 1}`, + } - if (field.b_date) { - const dateParts = field.b_date.split("-") - if (dateParts.length === 3) { - const day = Number.parseInt(dateParts[0], 10) - const month = Number.parseInt(dateParts[1], 10) - 1 - const year = Number.parseInt(dateParts[2], 10) - soilAnalysis.b_sampling_date = new Date(year, month, day) - } - } + // Safely map known soil parameters (starting with a_) + for (const key of Object.keys(field).filter((key) => + key.startsWith("a_"), + )) { + soilAnalysis[key] = field[key] + } - if (field.b_soiltype_agr) { - soilAnalysis.b_soil_type = field.b_soiltype_agr + if (field.b_date) { + const dateParts = field.b_date.split("-") + if (dateParts.length === 3) { + const day = Number.parseInt(dateParts[0], 10) + const month = Number.parseInt(dateParts[1], 10) - 1 + const year = Number.parseInt(dateParts[2], 10) + soilAnalysis.b_sampling_date = new Date(year, month, day) } + } + + if (field.b_soiltype_agr) { + soilAnalysis.b_soil_type = field.b_soiltype_agr + } - if (field.b_depth) { - const depthParts = field.b_depth.split("-") - if (depthParts.length !== 2) { - throw new Error(`Invalid depth format: ${field.b_depth}`) - } - const upper = Number(depthParts[0]) - const lower = Number(depthParts[1]) - if (Number.isNaN(upper) || Number.isNaN(lower)) { - throw new Error( - `Invalid numeric depth values: ${field.b_depth}`, - ) - } - soilAnalysis.a_depth_upper = upper - soilAnalysis.a_depth_lower = lower + if (field.b_depth) { + const depthParts = field.b_depth.split("-") + if (depthParts.length !== 2) { + throw new Error(`Invalid depth format: ${field.b_depth}`) } + const upper = Number(depthParts[0]) + const lower = Number(depthParts[1]) + if (Number.isNaN(upper) || Number.isNaN(lower)) { + throw new Error( + `Invalid numeric depth values: ${field.b_depth}`, + ) + } + soilAnalysis.a_depth_upper = upper + soilAnalysis.a_depth_lower = lower + } - // Add coordinates for geometry matching, but keep them separate from the main analysis data - if (field.a_lat && field.a_lon) { - soilAnalysis.location = { - type: "Point", - coordinates: [Number(field.a_lon), Number(field.a_lat)], - } + // Add coordinates for geometry matching, but keep them separate from the main analysis data + if (field.a_lat && field.a_lon) { + soilAnalysis.location = { + type: "Point", + coordinates: [Number(field.a_lon), Number(field.a_lat)], } + } - return soilAnalysis - }) - } catch (e) { - console.error("Failed to parse NMI API response:", text) - throw e - } + return soilAnalysis + }) } diff --git a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx index 20efea63e..9a5b19287 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx @@ -4,7 +4,6 @@ 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, getSoilParametersDescription, @@ -267,6 +266,6 @@ export async function action({ request, params }: ActionFunctionArgs) { return data({ message: "Invalid request" }, { status: 400 }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx index b7f1f87f8..d16a4cd96 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx @@ -234,7 +234,8 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } // Strip UI-only properties before saving to DB - const { id, location, a_source, ...dbAnalysis } = analysis + const { id, location, a_source, ...dbAnalysis } = + analysis return addSoilAnalysis( fdm, @@ -262,6 +263,6 @@ export async function action({ request, params }: ActionFunctionArgs) { return data({ message: "Invalid request" }, { status: 400 }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } From 2d1c8bde80deca7e69b941a4745e96cabe1006d5 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:40:17 +0100 Subject: [PATCH 02/23] feat: add progress bar during upload of multiple pdf's --- .../blocks/soil/bulk-upload-form.tsx | 89 ++++++++++++++++--- fdm-app/app/components/custom/dropzone.tsx | 23 +++-- fdm-app/app/integrations/nmi.ts | 22 +++-- .../farm.$b_id_farm.soil-analysis.bulk.tsx | 7 +- ...b_id_farm.$calendar.soil-analysis.bulk.tsx | 7 +- 5 files changed, 114 insertions(+), 34 deletions(-) diff --git a/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx b/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx index fcb20215a..a26c1cb71 100644 --- a/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx +++ b/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react" +import { useState, useEffect, useRef } from "react" import { useFetcher } from "react-router" import { FileText, Upload, Trash2, X, FileUp } from "lucide-react" import { Dropzone } from "~/components/custom/dropzone" @@ -13,6 +13,8 @@ import { import { Spinner } from "~/components/ui/spinner" import { ScrollArea } from "~/components/ui/scroll-area" import { cn } from "~/lib/utils" +import { Progress } from "~/components/ui/progress" +import { toast } from "sonner" export function BulkSoilAnalysisUploadForm({ onSuccess, @@ -20,10 +22,16 @@ export function BulkSoilAnalysisUploadForm({ onSuccess: (data: any[]) => void }) { const [files, setFiles] = useState([]) - const fetcher = useFetcher() - const isUploading = fetcher.state !== "idle" + const [isUploading, setIsUploading] = useState(false) + const [uploadProgress, setUploadProgress] = useState(0) + const [currentFile, setCurrentFile] = useState(null) const MAX_FILE_SIZE = 5 * 1024 * 1024 + const fetcher = useFetcher() + const uploadQueueRef = useRef([]) + const allResultsRef = useRef([]) + const totalFilesRef = useRef(0) + const handleFilesChange = (newFiles: File[]) => { setFiles(newFiles) } @@ -31,24 +39,65 @@ export function BulkSoilAnalysisUploadForm({ const handleUpload = () => { if (files.length === 0) return - const formData = new FormData() - for (const file of files) { - formData.append("soilAnalysisFile", file) + setIsUploading(true) + setUploadProgress(0) + allResultsRef.current = [] + uploadQueueRef.current = [...files] + totalFilesRef.current = files.length + + processNextFile() + } + + const processNextFile = () => { + const nextFile = uploadQueueRef.current.shift() + if (!nextFile) { + // Done! + setIsUploading(false) + setCurrentFile(null) + if (allResultsRef.current.length > 0) { + toast.success(`${allResultsRef.current.length} analyses succesvol verwerkt`) + onSuccess(allResultsRef.current) + } else if (totalFilesRef.current > 0) { + toast.error("Geen analyses kunnen verwerken") + } + return } + setCurrentFile(nextFile.name) + const formData = new FormData() + formData.append("soilAnalysisFile", nextFile) + fetcher.submit(formData, { method: "POST", encType: "multipart/form-data", }) } + // Monitor fetcher state to process the queue useEffect(() => { - if (isUploading) return + if (!isUploading || fetcher.state !== "idle") return - if (fetcher.data?.analyses) { - onSuccess(fetcher.data.analyses) + if (fetcher.data) { + const analyses = (fetcher.data as any).analyses + if (analyses) { + if (Array.isArray(analyses)) { + allResultsRef.current.push(...analyses) + } else { + allResultsRef.current.push(analyses) + } + } else if ((fetcher.data as any).warning) { + toast.error(`Fout bij ${currentFile}: ${(fetcher.data as any).warning}`) + } } - }, [fetcher.data, isUploading, onSuccess]) + + // Update progress + const completedCount = totalFilesRef.current - uploadQueueRef.current.length + setUploadProgress(Math.round((completedCount / totalFilesRef.current) * 100)) + + // Small delay for UI smoothness before next file + const timeout = setTimeout(processNextFile, 50) + return () => clearTimeout(timeout) + }, [fetcher.state, fetcher.data, isUploading]) const removeFile = (index: number) => { const newFiles = [...files] @@ -90,6 +139,7 @@ export function BulkSoilAnalysisUploadForm({ onFilesChange={handleFilesChange} allowReset={false} maxSize={MAX_FILE_SIZE} + disabled={isUploading} className={cn( "w-full transition-all duration-200 border-2", files.length > 0 @@ -197,6 +247,25 @@ export function BulkSoilAnalysisUploadForm({ + {isUploading && ( +
+
+ + {currentFile + ? `Analyseert: ${currentFile}` + : "Bestanden verwerken..."} + + + {uploadProgress}% + +
+ +
+ )} +
- - - + Geen resultaten. + + + )} + + +
+ + + + + + + ) } diff --git a/fdm-app/app/integrations/nmi.ts b/fdm-app/app/integrations/nmi.ts index 8e4e0f250..a73ab105e 100644 --- a/fdm-app/app/integrations/nmi.ts +++ b/fdm-app/app/integrations/nmi.ts @@ -3,8 +3,18 @@ import type { Feature, Geometry, Polygon } from "geojson" import { z } from "zod" import { serverConfig } from "~/lib/config.server" import { fileTypeFromBuffer } from "file-type" +import proj4 from "proj4" + const MAX_PDF_SIZE = 5 * 1024 * 1024 +// Register the projection for RD New (EPSG:28992) +if (!proj4.defs("EPSG:28992")) { + proj4.defs( + "EPSG:28992", + "+proj=sterea +lat_0=52.15616055555555 +lon_0=5.38763888888889 +k=0.9999079 +x_0=155000 +y_0=463000 +ellps=bessel +towgs84=565.2369,50.0087,465.658,-0.406857330322398,0.350732676542563,-1.8703473836068,4.0812 +units=m +no_defs", + ) +} + export function getNmiApiKey() { if (!serverConfig.integrations.nmi) { return undefined @@ -207,6 +217,7 @@ export async function extractSoilAnalysis(formData: FormData) { // Select the a_* parameters const soilAnalysis: { [key: string]: string | number | Date } = {} + soilAnalysis.b_name = field.b_fieldname // Map b_fieldname for name matching for (const key of Object.keys(field).filter((key) => key.startsWith("a_"), )) { @@ -248,6 +259,31 @@ export async function extractSoilAnalysis(formData: FormData) { throw new Error(`Invalid numeric depth values: ${field.b_depth}`) } } + + // Add coordinates for geometry matching + const x_rd = field.b_loc_x + const y_rd = field.b_loc_y + + if (x_rd && y_rd) { + const numericX = Number(x_rd) + const numericY = Number(y_rd) + + if (!Number.isNaN(numericX) && !Number.isNaN(numericY)) { + try { + const [lon, lat] = proj4("EPSG:28992", "EPSG:4326", [ + numericX, + numericY, + ]) + soilAnalysis.location = { + type: "Point", + coordinates: [lon, lat], + } + } catch (e) { + console.error("Coordinate transformation failed:", e) + } + } + } + return soilAnalysis } @@ -357,6 +393,7 @@ export async function extractBulkSoilAnalyses(formData: FormData) { const soilAnalysis: { [key: string]: any } = { id: crypto.randomUUID(), // Used for UI matching filename: field.filename || `Analyse ${index + 1}`, + b_name: field.b_fieldname, // Map b_fieldname for name matching } // Safely map known soil parameters (starting with a_) @@ -397,10 +434,46 @@ export async function extractBulkSoilAnalyses(formData: FormData) { } // Add coordinates for geometry matching, but keep them separate from the main analysis data - if (field.a_lat && field.a_lon) { - soilAnalysis.location = { - type: "Point", - coordinates: [Number(field.a_lon), Number(field.a_lat)], + // NMI API uses RD New (EPSG:28992) with keys b_loc_x and b_loc_y + const x_rd = field.b_loc_x + const y_rd = field.b_loc_y + + if (x_rd && y_rd) { + const numericX = Number(x_rd) + const numericY = Number(y_rd) + + if (!Number.isNaN(numericX) && !Number.isNaN(numericY)) { + try { + // Transform from RD New to WGS84 + const [lon, lat] = proj4("EPSG:28992", "EPSG:4326", [ + numericX, + numericY, + ]) + soilAnalysis.location = { + type: "Point", + coordinates: [lon, lat], + } + } catch (e) { + console.error("Coordinate transformation failed:", e) + } + } + } + + // Fallback for WGS84 if provided directly under other keys + if (!soilAnalysis.location) { + const lat = field.a_lat || field.latitude || field.lat + const lon = field.a_lon || field.longitude || field.lon + + if (lat && lon) { + const numericLat = Number(lat) + const numericLon = Number(lon) + + if (!Number.isNaN(numericLat) && !Number.isNaN(numericLon)) { + soilAnalysis.location = { + type: "Point", + coordinates: [numericLon, numericLat], + } + } } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx index 6f20bf532..c83fa81d5 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx @@ -82,35 +82,57 @@ export default function BulkSoilAnalysisUploadPage() { // Perform geometry matching const matchedAnalyses = analyses.map((analysis) => { let matchedFieldId = "" + let matchReason: "geometry" | "name" | "both" | undefined - if (analysis.location) { - const fieldMatch = fields.find((field) => { - if (!field.geometry) return false - try { - return booleanPointInPolygon( - analysis.location, - field.geometry, - ) - } catch (e) { - return false + if (analysis.location && analysis.location.coordinates) { + const [lon, lat] = analysis.location.coordinates + if (typeof lon === "number" && typeof lat === "number") { + const fieldMatch = fields.find((field) => { + if (!field.geometry) return false + try { + return booleanPointInPolygon( + analysis.location, + field.geometry as any, + ) + } catch (e) { + console.warn(`Matching failed for field ${field.b_name}:`, e) + return false + } + }) + if (fieldMatch) { + matchedFieldId = fieldMatch.b_id + matchReason = "geometry" } - }) - if (fieldMatch) matchedFieldId = fieldMatch.b_id + } } - // If no geometry match, try name match - if (!matchedFieldId) { + // If no geometry match, try name match (b_fieldname vs field name) + const analysisName = (analysis.b_name || "") + .toLowerCase() + .trim() + + if (analysisName) { const fieldMatch = fields.find( (field) => - field.b_name.toLowerCase() === - analysis.filename.replace(/\.pdf$/i, "").toLowerCase(), + field.b_name.toLowerCase().trim() === analysisName, ) - if (fieldMatch) matchedFieldId = fieldMatch.b_id + + if (fieldMatch) { + if (matchedFieldId) { + if (matchedFieldId === fieldMatch.b_id) { + matchReason = "both" + } + } else { + matchedFieldId = fieldMatch.b_id + matchReason = "name" + } + } } return { ...analysis, matchedFieldId, + matchReason, } }) diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx index b009848bf..93c5dab3e 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx @@ -82,34 +82,60 @@ export default function BulkSoilAnalysisUploadWizardPage() { const handleUploadSuccess = (analyses: any[]) => { const matchedAnalyses = analyses.map((analysis) => { let matchedFieldId = "" + let matchReason: "geometry" | "name" | "both" | undefined - if (analysis.location) { - const fieldMatch = fields.find((field) => { - if (!field.geometry) return false - try { - return booleanPointInPolygon( - analysis.location, - field.geometry, - ) - } catch (e) { - return false + // Geometry matching + if (analysis.location && analysis.location.coordinates) { + const [lon, lat] = analysis.location.coordinates + if (typeof lon === "number" && typeof lat === "number") { + const fieldMatch = fields.find((field) => { + if (!field.geometry) return false + try { + // booleanPointInPolygon handles both Polygon and MultiPolygon + return booleanPointInPolygon( + analysis.location, + field.geometry as any, + ) + } catch (e) { + console.warn(`Matching failed for field ${field.b_name}:`, e) + return false + } + }) + if (fieldMatch) { + matchedFieldId = fieldMatch.b_id + matchReason = "geometry" } - }) - if (fieldMatch) matchedFieldId = fieldMatch.b_id + } } - if (!matchedFieldId) { + // Fallback: Name matching (b_fieldname vs field name) + const analysisName = (analysis.b_name || "") + .toLowerCase() + .trim() + + if (analysisName) { const fieldMatch = fields.find( (field) => - field.b_name.toLowerCase() === - analysis.filename.replace(/\.pdf$/i, "").toLowerCase(), + field.b_name.toLowerCase().trim() === analysisName, ) - if (fieldMatch) matchedFieldId = fieldMatch.b_id + + if (fieldMatch) { + if (matchedFieldId) { + // Check if it's the same field + if (matchedFieldId === fieldMatch.b_id) { + matchReason = "both" + } + } else { + matchedFieldId = fieldMatch.b_id + matchReason = "name" + } + } } return { ...analysis, matchedFieldId, + matchReason, } }) From 9b5cd8ae4be57f08d77acaa2329afc99629902ea Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:05:07 +0100 Subject: [PATCH 05/23] feat: improvements for nmin analysis --- .../blocks/soil/bulk-upload-review.tsx | 314 +++++++++++++----- fdm-app/app/integrations/nmi.ts | 5 +- .../farm.$b_id_farm.soil-analysis.bulk.tsx | 7 +- ...b_id_farm.$calendar.soil-analysis.bulk.tsx | 7 +- 4 files changed, 241 insertions(+), 92 deletions(-) diff --git a/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx b/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx index 2db844bf1..bbdcb94e8 100644 --- a/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx +++ b/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx @@ -30,9 +30,17 @@ import { CardTitle, } from "~/components/ui/card" import { Badge } from "~/components/ui/badge" -import { Check, AlertTriangle, Save, X, Microscope } from "lucide-react" +import { + Check, + AlertTriangle, + Save, + X, + Microscope, + CalendarIcon, + Shovel, +} from "lucide-react" import type { SoilParameterDescription } from "@svenvw/fdm-core" -import { format } from "date-fns/format" +import { format, isValid, parseISO } from "date-fns" import { nl } from "date-fns/locale/nl" import { Tooltip, @@ -40,11 +48,34 @@ import { TooltipProvider, TooltipTrigger, } from "~/components/ui/tooltip" +import { Input } from "~/components/ui/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover" +import { Calendar } from "~/components/ui/calendar" +import * as chrono from "chrono-node" +import { endMonth } from "~/lib/calendar" const isValidDate = (dateStr: string | undefined | null): boolean => { if (!dateStr) return false const d = new Date(dateStr) - return !Number.isNaN(d.getTime()) + return isValid(d) +} + +function parseDateText(date: string | Date | undefined): Date | undefined { + if (date instanceof Date) return date + if (!date) return undefined + + // Attempt to parse as ISO string first + const isoDate = parseISO(date) + if (isValid(isoDate)) return isoDate + + // Fallback to chrono-node for localized strings + const referenceDate = new Date() + const parsedDate = chrono.nl.parseDate(date, referenceDate) + return parsedDate || undefined } export type ProcessedAnalysis = { @@ -55,6 +86,10 @@ export type ProcessedAnalysis = { a_p_al?: number a_p_cc?: number a_nmin_cc?: number + a_nh4_cc?: number + a_no3_cc?: number + a_depth_upper?: number + a_depth_lower?: number a_source: string matchedFieldId?: string matchReason?: "geometry" | "name" | "both" @@ -76,29 +111,54 @@ export function BulkSoilAnalysisReview({ analyses: ProcessedAnalysis[] fields: Field[] soilParameterDescription: SoilParameterDescription - onSave: (matches: { analysisId: string; fieldId: string }[]) => void + onSave: ( + matches: { analysisId: string; fieldId: string }[], + updatedAnalyses: ProcessedAnalysis[], + ) => void onCancel: () => void }) { const [matches, setMatches] = useState>( Object.fromEntries(analyses.map((a) => [a.id, a.matchedFieldId || ""])), ) + const [dates, setDates] = useState>( + Object.fromEntries( + analyses.map((a) => [ + a.id, + a.b_sampling_date + ? new Date(a.b_sampling_date).toISOString().split("T")[0] + : "", + ]), + ), + ) const handleFieldChange = (analysisId: string, fieldId: string) => { setMatches((prev) => ({ ...prev, [analysisId]: fieldId })) } + const handleDateChange = (analysisId: string, date: string) => { + setDates((prev) => ({ ...prev, [analysisId]: date })) + } + const validMatches = useMemo( () => Object.entries(matches) .filter(([analysisId, fieldId]) => { if (fieldId === "" || fieldId === "none") return false - const analysis = analyses.find((a) => a.id === analysisId) - return isValidDate(analysis?.b_sampling_date) + const date = dates[analysisId] + return isValidDate(date) }) .map(([analysisId, fieldId]) => ({ analysisId, fieldId })), - [matches, analyses], + [matches, dates], ) + const handleSave = () => { + const updatedAnalyses = analyses.map((a) => ({ + ...a, + b_sampling_date: dates[a.id], + })) + onSave(validMatches, updatedAnalyses) + } + const columns: ColumnDef[] = [ { accessorKey: "filename", @@ -118,9 +178,22 @@ export function BulkSoilAnalysisReview({ {row.original.filename} -
- - {sourceLabel} +
+
+ + {sourceLabel} +
+ {(row.original.a_depth_upper !== undefined || + row.original.a_depth_lower !== undefined) && ( +
+ + + Diepte:{" "} + {row.original.a_depth_upper ?? 0} -{" "} + {row.original.a_depth_lower ?? "?"} cm + +
+ )}
) @@ -130,10 +203,86 @@ export function BulkSoilAnalysisReview({ accessorKey: "b_sampling_date", header: "Datum", cell: ({ row }) => { - if (!isValidDate(row.original.b_sampling_date)) return "-" - return format(new Date(row.original.b_sampling_date), "P", { - locale: nl, - }) + const dateStr = dates[row.original.id] + const date = dateStr ? new Date(dateStr) : undefined + const [open, setOpen] = useState(false) + const [inputValue, setInputValue] = useState( + date && isValid(date) + ? format(date, "PPP", { locale: nl }) + : "", + ) + + const onDateSelect = (d: Date | undefined) => { + if (d) { + const iso = d.toISOString().split("T")[0] + handleDateChange(row.original.id, iso) + setInputValue(format(d, "PPP", { locale: nl })) + } else { + handleDateChange(row.original.id, "") + setInputValue("") + } + setOpen(false) + } + + const onInputBlur = () => { + const parsed = parseDateText(inputValue) + if (parsed && isValid(parsed)) { + const iso = parsed.toISOString().split("T")[0] + handleDateChange(row.original.id, iso) + setInputValue(format(parsed, "PPP", { locale: nl })) + } else if (inputValue === "") { + handleDateChange(row.original.id, "") + } + } + + return ( +
+ setInputValue(e.target.value)} + onBlur={onInputBlur} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + e.currentTarget.blur() + } + if (e.key === "ArrowDown") { + e.preventDefault() + setOpen(true) + } + }} + /> + + + + + + + + +
+ ) }, }, { @@ -148,7 +297,7 @@ export function BulkSoilAnalysisReview({ )} {row.original.a_p_al != null && ( - P-AL: {row.original.a_p_al} + P-Al: {row.original.a_p_al} )} {row.original.a_p_cc != null && ( @@ -161,32 +310,50 @@ export function BulkSoilAnalysisReview({ Nmin: {row.original.a_nmin_cc} )} + {row.original.a_nh4_cc != null && ( + + NH₄: {row.original.a_nh4_cc} + + )} + {row.original.a_no3_cc != null && ( + + NO₃: {row.original.a_no3_cc} + + )} ), }, { id: "match", header: "Perceel", - cell: ({ row }) => ( - + handleFieldChange(row.original.id, value) + } + > + + + + + + -- Geen perceel -- - ))} - - - ), + {fields.map((field) => ( + + {field.b_name} + + ))} + + + ) + }, }, { id: "status", @@ -194,21 +361,21 @@ export function BulkSoilAnalysisReview({ cell: ({ row }) => { const matchId = matches[row.original.id] const isMatched = matchId && matchId !== "none" - const isValid = isValidDate(row.original.b_sampling_date) + const date = dates[row.original.id] + const isDateValid = isValidDate(date) const reason = row.original.matchReason const initialMatchId = row.original.matchedFieldId - if (!isValid) { + if (!isDateValid) { return (
- Ongeldige pdf + Datum ontbreekt
) } if (isMatched) { - // It's only an automatic match if the current selection is the same as the initial one const isAutomatic = matchId === initialMatchId let tooltipText = "Handmatig gekoppeld" @@ -217,7 +384,8 @@ export function BulkSoilAnalysisReview({ tooltipText = "Automatisch gekoppeld op basis van geometrie" else if (reason === "name") - tooltipText = "Automatisch gekoppeld op basis van naam" + tooltipText = + "Automatisch gekoppeld op basis van naam" else if (reason === "both") tooltipText = "Automatisch gekoppeld op basis van geometrie en naam" @@ -254,10 +422,6 @@ export function BulkSoilAnalysisReview({ getCoreRowModel: getCoreRowModel(), }) - const handleSave = () => { - onSave(validMatches) - } - return ( @@ -270,7 +434,7 @@ export function BulkSoilAnalysisReview({ -
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -291,47 +455,27 @@ export function BulkSoilAnalysisReview({ {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => { - const isValid = isValidDate( - row.original.b_sampling_date, - ) - - return ( - - {row - .getVisibleCells() - .map((cell) => ( - - {cell.column.id === - "match" && - !isValid ? ( -
- Niet koppelbaar -
- ) : ( - flexRender( - cell.column - .columnDef - .cell, - cell.getContext(), - ) - )} -
- ))} -
- ) - }) + table.getRowModel().rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell.column + .columnDef.cell, + cell.getContext(), + )} + + ))} + + )) ) : ( { + const handleSave = ( + matches: { analysisId: string; fieldId: string }[], + updatedAnalyses: ProcessedAnalysis[], + ) => { const formData = new FormData() // Filter out "none" selections const validMatches = matches.filter( (m) => m.fieldId !== "none" && m.fieldId !== "", ) formData.append("matches", JSON.stringify(validMatches)) - formData.append("analysesData", JSON.stringify(processedAnalyses)) + formData.append("analysesData", JSON.stringify(updatedAnalyses)) submit(formData, { method: "post" }) } diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx index 93c5dab3e..ddf3942b9 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx @@ -143,14 +143,17 @@ export default function BulkSoilAnalysisUploadWizardPage() { setStep("review") } - const handleSave = (matches: { analysisId: string; fieldId: string }[]) => { + const handleSave = ( + matches: { analysisId: string; fieldId: string }[], + updatedAnalyses: ProcessedAnalysis[], + ) => { const formData = new FormData() // Filter out "none" selections const validMatches = matches.filter( (m) => m.fieldId !== "none" && m.fieldId !== "", ) formData.append("matches", JSON.stringify(validMatches)) - formData.append("analysesData", JSON.stringify(processedAnalyses)) + formData.append("analysesData", JSON.stringify(updatedAnalyses)) submit(formData, { method: "post" }) } From 34c9408d331404b8fbbedaaed7424cd537757cd1 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:11:44 +0100 Subject: [PATCH 06/23] nitpicks --- .../blocks/soil/bulk-upload-review.tsx | 6 +++--- fdm-app/app/integrations/nmi.ts | 6 +----- fdm-app/app/routes/api.soil-analysis.extract.ts | 8 ++++---- .../farm.$b_id_farm.soil-analysis.bulk.tsx | 16 ++++++++++++++-- ....$b_id_farm.$calendar.soil-analysis.bulk.tsx | 17 ++++++++++++++--- 5 files changed, 36 insertions(+), 17 deletions(-) diff --git a/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx b/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx index bbdcb94e8..c3c6fcad8 100644 --- a/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx +++ b/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx @@ -125,7 +125,7 @@ export function BulkSoilAnalysisReview({ analyses.map((a) => [ a.id, a.b_sampling_date - ? new Date(a.b_sampling_date).toISOString().split("T")[0] + ? format(new Date(a.b_sampling_date), "yyyy-MM-dd") : "", ]), ), @@ -214,7 +214,7 @@ export function BulkSoilAnalysisReview({ const onDateSelect = (d: Date | undefined) => { if (d) { - const iso = d.toISOString().split("T")[0] + const iso = format(d, "yyyy-MM-dd") handleDateChange(row.original.id, iso) setInputValue(format(d, "PPP", { locale: nl })) } else { @@ -227,7 +227,7 @@ export function BulkSoilAnalysisReview({ const onInputBlur = () => { const parsed = parseDateText(inputValue) if (parsed && isValid(parsed)) { - const iso = parsed.toISOString().split("T")[0] + const iso = format(parsed, "yyyy-MM-dd") handleDateChange(row.original.id, iso) setInputValue(format(parsed, "PPP", { locale: nl })) } else if (inputValue === "") { diff --git a/fdm-app/app/integrations/nmi.ts b/fdm-app/app/integrations/nmi.ts index 14b43c3db..7e3b98b58 100644 --- a/fdm-app/app/integrations/nmi.ts +++ b/fdm-app/app/integrations/nmi.ts @@ -293,12 +293,8 @@ export async function extractBulkSoilAnalyses(formData: FormData) { throw new Error("NMI API key not configured") } - const files = formData.getAll("soilAnalysisFile") as File[] - if (files.length === 0) { - throw new Error("Geen bestanden gevonden in FormData") - } - // Filter out potential non-File objects or empty slots + const files = formData.getAll("soilAnalysisFile") as File[] const validFiles = files.filter(file => file instanceof File && file.name) if (validFiles.length === 0) { throw new Error("Geen geldige bestanden gevonden in FormData") diff --git a/fdm-app/app/routes/api.soil-analysis.extract.ts b/fdm-app/app/routes/api.soil-analysis.extract.ts index c06d54276..5819ca9ef 100644 --- a/fdm-app/app/routes/api.soil-analysis.extract.ts +++ b/fdm-app/app/routes/api.soil-analysis.extract.ts @@ -41,15 +41,15 @@ export async function action({ request }: ActionFunctionArgs) { const encoder = new TextEncoder() const stream = new ReadableStream({ async start(controller) { - const queue = [...files] + let nextIndex = 0 const concurrency = 10 const workers = Array.from( { length: Math.min(concurrency, files.length) }, async () => { - while (queue.length > 0) { - const file = queue.shift() - if (!file) break + while (nextIndex < files.length) { + const file = files[nextIndex++] + if (!file) continue try { // Create a minimal FormData for a single file extraction diff --git a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx index c4135caba..8a39158ed 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx @@ -260,8 +260,20 @@ export async function action({ request, params }: ActionFunctionArgs) { `Analysis ${match.analysisId}: invalid b_sampling_date (${analysis.b_sampling_date})`, ) } - // Strip UI-only properties before saving to DB - const { id, location, ...dbAnalysis } = analysis + // Strip UI-only and redundant properties before saving to DB + const { + id, + location, + a_source, + matchedFieldId, + matchReason, + filename, + b_name, + b_sampling_date, + a_depth_upper, + a_depth_lower, + ...dbAnalysis + } = analysis return addSoilAnalysis( fdm, diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx index ddf3942b9..5dd2b6966 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx @@ -257,9 +257,20 @@ export async function action({ request, params }: ActionFunctionArgs) { `Analysis ${match.analysisId}: invalid b_sampling_date (${analysis.b_sampling_date})`, ) } - // Strip UI-only properties before saving to DB - const { id, location, a_source, ...dbAnalysis } = - analysis + // Strip UI-only and redundant properties before saving to DB + const { + id, + location, + a_source, + matchedFieldId, + matchReason, + filename, + b_name, + b_sampling_date, + a_depth_upper, + a_depth_lower, + ...dbAnalysis + } = analysis return addSoilAnalysis( fdm, From e14567bb33a284d6058cea6e444535873638466c Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:14:50 +0100 Subject: [PATCH 07/23] fix: rerendering after submission --- fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx | 2 +- .../farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx index 8a39158ed..38e9b7578 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx @@ -76,7 +76,7 @@ export default function BulkSoilAnalysisUploadPage() { const submit = useSubmit() const isSaving = - navigation.state === "submitting" && navigation.formData?.has("matches") + navigation.state !== "idle" && navigation.formMethod === "POST" const handleUploadSuccess = (analyses: any[]) => { // Perform geometry matching diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx index 5dd2b6966..f17a31e77 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx @@ -77,7 +77,7 @@ export default function BulkSoilAnalysisUploadWizardPage() { const submit = useSubmit() const isSaving = - navigation.state === "submitting" && navigation.formData?.has("matches") + navigation.state !== "idle" && navigation.formMethod === "POST" const handleUploadSuccess = (analyses: any[]) => { const matchedAnalyses = analyses.map((analysis) => { From e5386dcceca8ef2786ed32156c0ff33223232e38 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:33:29 +0100 Subject: [PATCH 08/23] fix: handle end of stream --- .../blocks/soil/bulk-upload-form.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx b/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx index c411c3a8c..fc846b707 100644 --- a/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx +++ b/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx @@ -94,6 +94,27 @@ export function BulkSoilAnalysisUploadForm({ } } } + + // Process final partial line if exists + if (buffer.trim()) { + try { + const result = JSON.parse(buffer) + completedFiles++ + + if (result.success && result.analyses) { + allResults.push(...result.analyses) + } else if (result.error) { + toast.error(`Fout bij ${result.filename}: ${result.error}`) + } + + setCurrentFile(result.filename) + setUploadProgress( + Math.round((completedFiles / totalFiles) * 100), + ) + } catch (e) { + console.error("Error parsing final NDJSON line:", e) + } + } } catch (error) { console.error("Bulk upload error:", error) toast.error(error instanceof Error ? error.message : "Upload mislukt") From 3058ae54e1bf41180e8f4c4761061a68a7a159d7 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:34:49 +0100 Subject: [PATCH 09/23] fix: double error message --- fdm-app/app/components/blocks/soil/bulk-upload-form.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx b/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx index fc846b707..05fd0e56e 100644 --- a/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx +++ b/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx @@ -39,6 +39,7 @@ export function BulkSoilAnalysisUploadForm({ const allResults: any[] = [] const totalFiles = files.length let completedFiles = 0 + let errorOccurred = false const formData = new FormData() for (const file of files) { @@ -118,6 +119,7 @@ export function BulkSoilAnalysisUploadForm({ } catch (error) { console.error("Bulk upload error:", error) toast.error(error instanceof Error ? error.message : "Upload mislukt") + errorOccurred = true } finally { setIsUploading(false) setCurrentFile(null) @@ -125,7 +127,7 @@ export function BulkSoilAnalysisUploadForm({ if (allResults.length > 0) { toast.success(`${allResults.length} analyses succesvol verwerkt`) onSuccess(allResults) - } else if (totalFiles > 0) { + } else if (!errorOccurred && totalFiles > 0) { toast.error("Geen analyses kunnen verwerken") } } From e4c50502e4f3b723f6531708dd905c1aaffe04e6 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:35:46 +0100 Subject: [PATCH 10/23] fix: handle invalid date --- .../components/blocks/soil/bulk-upload-review.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx b/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx index c3c6fcad8..9febbd8f2 100644 --- a/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx +++ b/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx @@ -122,12 +122,14 @@ export function BulkSoilAnalysisReview({ ) const [dates, setDates] = useState>( Object.fromEntries( - analyses.map((a) => [ - a.id, - a.b_sampling_date - ? format(new Date(a.b_sampling_date), "yyyy-MM-dd") - : "", - ]), + analyses.map((a) => { + const date = a.b_sampling_date + ? new Date(a.b_sampling_date) + : null + const validDateStr = + date && isValid(date) ? format(date, "yyyy-MM-dd") : "" + return [a.id, validDateStr] + }), ), ) From a21728c0e23eac71236c364e409453341382fc1a Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:37:33 +0100 Subject: [PATCH 11/23] fix: hook rule violation --- .../blocks/soil/bulk-upload-review.tsx | 175 ++++++++++-------- 1 file changed, 93 insertions(+), 82 deletions(-) diff --git a/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx b/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx index 9febbd8f2..3dd5cf7a2 100644 --- a/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx +++ b/fdm-app/app/components/blocks/soil/bulk-upload-review.tsx @@ -78,6 +78,92 @@ function parseDateText(date: string | Date | undefined): Date | undefined { return parsedDate || undefined } +function DateCell({ + analysisId, + initialDateStr, + onDateChange, +}: { + analysisId: string + initialDateStr: string + onDateChange: (id: string, date: string) => void +}) { + const date = initialDateStr ? new Date(initialDateStr) : undefined + const [open, setOpen] = useState(false) + const [inputValue, setInputValue] = useState( + date && isValid(date) ? format(date, "PPP", { locale: nl }) : "", + ) + + const onDateSelect = (d: Date | undefined) => { + if (d) { + const iso = format(d, "yyyy-MM-dd") + onDateChange(analysisId, iso) + setInputValue(format(d, "PPP", { locale: nl })) + } else { + onDateChange(analysisId, "") + setInputValue("") + } + setOpen(false) + } + + const onInputBlur = () => { + const parsed = parseDateText(inputValue) + if (parsed && isValid(parsed)) { + const iso = format(parsed, "yyyy-MM-dd") + onDateChange(analysisId, iso) + setInputValue(format(parsed, "PPP", { locale: nl })) + } else if (inputValue === "") { + onDateChange(analysisId, "") + } + } + + return ( +
+ setInputValue(e.target.value)} + onBlur={onInputBlur} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + e.currentTarget.blur() + } + if (e.key === "ArrowDown") { + e.preventDefault() + setOpen(true) + } + }} + /> + + + + + + + + +
+ ) +} + export type ProcessedAnalysis = { id: string filename: string @@ -204,88 +290,13 @@ export function BulkSoilAnalysisReview({ { accessorKey: "b_sampling_date", header: "Datum", - cell: ({ row }) => { - const dateStr = dates[row.original.id] - const date = dateStr ? new Date(dateStr) : undefined - const [open, setOpen] = useState(false) - const [inputValue, setInputValue] = useState( - date && isValid(date) - ? format(date, "PPP", { locale: nl }) - : "", - ) - - const onDateSelect = (d: Date | undefined) => { - if (d) { - const iso = format(d, "yyyy-MM-dd") - handleDateChange(row.original.id, iso) - setInputValue(format(d, "PPP", { locale: nl })) - } else { - handleDateChange(row.original.id, "") - setInputValue("") - } - setOpen(false) - } - - const onInputBlur = () => { - const parsed = parseDateText(inputValue) - if (parsed && isValid(parsed)) { - const iso = format(parsed, "yyyy-MM-dd") - handleDateChange(row.original.id, iso) - setInputValue(format(parsed, "PPP", { locale: nl })) - } else if (inputValue === "") { - handleDateChange(row.original.id, "") - } - } - - return ( -
- setInputValue(e.target.value)} - onBlur={onInputBlur} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - e.currentTarget.blur() - } - if (e.key === "ArrowDown") { - e.preventDefault() - setOpen(true) - } - }} - /> - - - - - - - - -
- ) - }, + cell: ({ row }) => ( + + ), }, { id: "parameters", From 0c37796b370435d14e2d1432b8d6d86abe75625c Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:38:07 +0100 Subject: [PATCH 12/23] fix: type --- fdm-app/app/integrations/nmi.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fdm-app/app/integrations/nmi.ts b/fdm-app/app/integrations/nmi.ts index 7e3b98b58..1a40b08f3 100644 --- a/fdm-app/app/integrations/nmi.ts +++ b/fdm-app/app/integrations/nmi.ts @@ -216,7 +216,10 @@ export async function extractSoilAnalysis(formData: FormData) { const field = response.fields[0] // Select the a_* parameters - const soilAnalysis: { [key: string]: string | number | Date } = {} + const soilAnalysis: { + [key: string]: string | number | Date | any + location?: { type: "Point"; coordinates: [number, number] } + } = {} soilAnalysis.b_name = field.b_fieldname // Map b_fieldname for name matching for (const key of Object.keys(field).filter((key) => key.startsWith("a_"), From c9196615459fe183ba061e230d2c8e9a401dd6bb Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:40:05 +0100 Subject: [PATCH 13/23] refactor: improve validation --- fdm-app/app/integrations/nmi.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/fdm-app/app/integrations/nmi.ts b/fdm-app/app/integrations/nmi.ts index 1a40b08f3..cf955ef23 100644 --- a/fdm-app/app/integrations/nmi.ts +++ b/fdm-app/app/integrations/nmi.ts @@ -240,7 +240,10 @@ export async function extractSoilAnalysis(formData: FormData) { const day = Number.parseInt(dateParts[0], 10) const month = Number.parseInt(dateParts[1], 10) - 1 // Month is 0-indexed const year = Number.parseInt(dateParts[2], 10) - soilAnalysis.b_sampling_date = new Date(year, month, day) + + if (!Number.isNaN(day) && !Number.isNaN(month) && !Number.isNaN(year)) { + soilAnalysis.b_sampling_date = new Date(year, month, day) + } } } if (field.b_soiltype_agr) { @@ -407,7 +410,10 @@ export async function extractBulkSoilAnalyses(formData: FormData) { const day = Number.parseInt(dateParts[0], 10) const month = Number.parseInt(dateParts[1], 10) - 1 const year = Number.parseInt(dateParts[2], 10) - soilAnalysis.b_sampling_date = new Date(year, month, day) + + if (!Number.isNaN(day) && !Number.isNaN(month) && !Number.isNaN(year)) { + soilAnalysis.b_sampling_date = new Date(year, month, day) + } } } From 0dfe71b4d7b00ff4ef17c1af9b5204d93011b08b Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:40:54 +0100 Subject: [PATCH 14/23] refactor: improve coordinate validation --- fdm-app/app/integrations/nmi.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fdm-app/app/integrations/nmi.ts b/fdm-app/app/integrations/nmi.ts index cf955ef23..c97584548 100644 --- a/fdm-app/app/integrations/nmi.ts +++ b/fdm-app/app/integrations/nmi.ts @@ -465,10 +465,10 @@ export async function extractBulkSoilAnalyses(formData: FormData) { // Fallback for WGS84 if provided directly under other keys if (!soilAnalysis.location) { - const lat = field.a_lat || field.latitude || field.lat - const lon = field.a_lon || field.longitude || field.lon + const lat = field.a_lat ?? field.latitude ?? field.lat + const lon = field.a_lon ?? field.longitude ?? field.lon - if (lat && lon) { + if (lat != null && lon != null) { const numericLat = Number(lat) const numericLon = Number(lon) From 6611ef0a2b04c9aeef8195abc7f55becadd50591 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:41:42 +0100 Subject: [PATCH 15/23] fix: use lowercase --- fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx | 3 ++- .../farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx index 38e9b7578..4f038f47d 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx @@ -76,7 +76,8 @@ export default function BulkSoilAnalysisUploadPage() { const submit = useSubmit() const isSaving = - navigation.state !== "idle" && navigation.formMethod === "POST" + navigation.state !== "idle" && + navigation.formMethod?.toLowerCase() === "post" const handleUploadSuccess = (analyses: any[]) => { // Perform geometry matching diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx index f17a31e77..cf20d984a 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx @@ -77,7 +77,8 @@ export default function BulkSoilAnalysisUploadWizardPage() { const submit = useSubmit() const isSaving = - navigation.state !== "idle" && navigation.formMethod === "POST" + navigation.state !== "idle" && + navigation.formMethod?.toLowerCase() === "post" const handleUploadSuccess = (analyses: any[]) => { const matchedAnalyses = analyses.map((analysis) => { From e154ce298a4ccfbe7c9bab07e5c1c1d47b0dad4d Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:43:41 +0100 Subject: [PATCH 16/23] refactor: remove not used code --- fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx | 6 ------ .../farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx | 5 ----- 2 files changed, 11 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx index 4f038f47d..420b75fbd 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx @@ -215,12 +215,6 @@ export async function action({ request, params }: ActionFunctionArgs) { const session = await getSession(request) const formData = await request.formData() - // Handle initial upload to NMI - if (formData.has("soilAnalysisFile")) { - const analyses = await extractBulkSoilAnalyses(formData) - return data({ analyses }) - } - // Handle final save if (formData.has("matches")) { const matchesRaw = formData.get("matches") as string diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx index cf20d984a..024266cd0 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx @@ -215,11 +215,6 @@ export async function action({ request, params }: ActionFunctionArgs) { const calendar = getCalendar(params) const formData = await request.formData() - if (formData.has("soilAnalysisFile")) { - const analyses = await extractBulkSoilAnalyses(formData) - return data({ analyses }) - } - if (formData.has("matches")) { const matches = JSON.parse(formData.get("matches") as string) const analysesData = JSON.parse( From 6325c689b022e396d2437c2758ff9da9e6d2f440 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:49:57 +0100 Subject: [PATCH 17/23] refactor: use same logic for dropping and selecting --- fdm-app/app/components/custom/dropzone.tsx | 43 +++++++++++----------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/fdm-app/app/components/custom/dropzone.tsx b/fdm-app/app/components/custom/dropzone.tsx index 3c99dc79d..c91d8d8dc 100644 --- a/fdm-app/app/components/custom/dropzone.tsx +++ b/fdm-app/app/components/custom/dropzone.tsx @@ -106,6 +106,22 @@ export const Dropzone = ({ else if (inputRef.current) inputRef.current.value = "" } + const syncFilesToInput = (filesToSync: File[]) => { + if (!inputRef.current) return + try { + const container = new DataTransfer() + for (const f of filesToSync) { + if (f instanceof File) { + container.items.add(f) + } + } + inputRef.current.files = container.files + } catch (err) { + // Fallback or silent ignore if DataTransfer is restricted + console.warn("Could not sync files to hidden input:", err) + } + } + const handleDragOver = (e: React.DragEvent) => { e.preventDefault() } @@ -156,13 +172,7 @@ export const Dropzone = ({ inputFiles = await handleFilesSet(files, validNewFiles) } - if (inputRef.current?.files) { - const container = new DataTransfer() - inputFiles.forEach((f) => { - container.items.add(f) - }) - inputRef.current.files = container.files - } + syncFilesToInput(inputFiles) } } @@ -176,24 +186,13 @@ export const Dropzone = ({ const finalFiles = await handleFilesSet(files, validNewFiles) - if (inputRef.current) { - try { - const container = new DataTransfer() - for (const f of finalFiles) { - if (f instanceof File) { - container.items.add(f) - } - } - inputRef.current.files = container.files - } catch (err) { - // Fallback or silent ignore if DataTransfer is restricted - console.warn("Could not sync files to hidden input:", err) - } - } + syncFilesToInput(finalFiles) + try { e.dataTransfer.clearData() } catch (err) { - // Ignore clearData errors + // clearData may throw in some browsers after drop + console.warn("Could not clear dataTransfer:", err) } } } From e155677ff4ae2fdbb49dc55630cf53d484f86f05 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:56:44 +0100 Subject: [PATCH 18/23] refactor: process batches after each other to prevent overwhelming the api --- fdm-app/app/integrations/nmi.ts | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/fdm-app/app/integrations/nmi.ts b/fdm-app/app/integrations/nmi.ts index c97584548..ec23f61b3 100644 --- a/fdm-app/app/integrations/nmi.ts +++ b/fdm-app/app/integrations/nmi.ts @@ -241,7 +241,11 @@ export async function extractSoilAnalysis(formData: FormData) { const month = Number.parseInt(dateParts[1], 10) - 1 // Month is 0-indexed const year = Number.parseInt(dateParts[2], 10) - if (!Number.isNaN(day) && !Number.isNaN(month) && !Number.isNaN(year)) { + if ( + !Number.isNaN(day) && + !Number.isNaN(month) && + !Number.isNaN(year) + ) { soilAnalysis.b_sampling_date = new Date(year, month, day) } } @@ -301,7 +305,7 @@ export async function extractBulkSoilAnalyses(formData: FormData) { // Filter out potential non-File objects or empty slots const files = formData.getAll("soilAnalysisFile") as File[] - const validFiles = files.filter(file => file instanceof File && file.name) + const validFiles = files.filter((file) => file instanceof File && file.name) if (validFiles.length === 0) { throw new Error("Geen geldige bestanden gevonden in FormData") } @@ -312,7 +316,6 @@ export async function extractBulkSoilAnalyses(formData: FormData) { const BATCH_SIZE = 10 const MAX_BATCH_BYTES = 20 * 1024 * 1024 // 20MB - const allFields: any[] = [] // Group files into batches based on count and total size let currentBatchFiles: File[] = [] @@ -336,8 +339,10 @@ export async function extractBulkSoilAnalyses(formData: FormData) { batches.push(currentBatchFiles) } - // Process each batch in parallel - const batchPromises = batches.map(async (batchFiles, i) => { + // Process each batch sequentially to avoid overwhelming the API + const allBatchFields: any[] = [] + for (let i = 0; i < batches.length; i++) { + const batchFiles = batches[i] const batchFormData = new FormData() for (const file of batchFiles) { batchFormData.append("soilAnalysisFile", file) @@ -382,15 +387,10 @@ export async function extractBulkSoilAnalyses(formData: FormData) { ) } - return responseData.fields - }) - - const allBatchFields = await Promise.all(batchPromises) - for (const fields of allBatchFields) { - allFields.push(...fields) + allBatchFields.push(...responseData.fields) } - return allFields.map((field: any, index: number) => { + return allBatchFields.map((field: any, index: number) => { const soilAnalysis: { [key: string]: any } = { id: crypto.randomUUID(), // Used for UI matching filename: field.filename || `Analyse ${index + 1}`, @@ -411,7 +411,11 @@ export async function extractBulkSoilAnalyses(formData: FormData) { const month = Number.parseInt(dateParts[1], 10) - 1 const year = Number.parseInt(dateParts[2], 10) - if (!Number.isNaN(day) && !Number.isNaN(month) && !Number.isNaN(year)) { + if ( + !Number.isNaN(day) && + !Number.isNaN(month) && + !Number.isNaN(year) + ) { soilAnalysis.b_sampling_date = new Date(year, month, day) } } From f567a1e4fdb434bfff94e3680e93d44a52b2ca32 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:00:02 +0100 Subject: [PATCH 19/23] refactor: moving matching into shared function --- .../blocks/soil/bulk-upload-match.ts | 75 +++++++++++++++++++ .../farm.$b_id_farm.soil-analysis.bulk.tsx | 60 +-------------- ...b_id_farm.$calendar.soil-analysis.bulk.tsx | 62 +-------------- 3 files changed, 79 insertions(+), 118 deletions(-) create mode 100644 fdm-app/app/components/blocks/soil/bulk-upload-match.ts diff --git a/fdm-app/app/components/blocks/soil/bulk-upload-match.ts b/fdm-app/app/components/blocks/soil/bulk-upload-match.ts new file mode 100644 index 000000000..ee5dc175a --- /dev/null +++ b/fdm-app/app/components/blocks/soil/bulk-upload-match.ts @@ -0,0 +1,75 @@ +import { booleanPointInPolygon } from "@turf/turf" +import type { ProcessedAnalysis } from "./bulk-upload-review" + +type Field = { + b_id: string + b_name: string + geometry: any +} + +/** + * Matches extracted soil analyses to existing farm fields using geometry and name. + */ +export function matchAnalysesToFields( + analyses: any[], + fields: Field[], +): ProcessedAnalysis[] { + return analyses.map((analysis) => { + let matchedFieldId = "" + let matchReason: "geometry" | "name" | "both" | undefined + + // Geometry matching + if (analysis.location && analysis.location.coordinates) { + const [lon, lat] = analysis.location.coordinates + if (typeof lon === "number" && typeof lat === "number") { + const fieldMatch = fields.find((field) => { + if (!field.geometry) return false + try { + // booleanPointInPolygon handles both Polygon and MultiPolygon + return booleanPointInPolygon( + analysis.location, + field.geometry as any, + ) + } catch (e) { + console.warn(`Matching failed for field ${field.b_name}:`, e) + return false + } + }) + if (fieldMatch) { + matchedFieldId = fieldMatch.b_id + matchReason = "geometry" + } + } + } + + // Fallback: Name matching (b_fieldname vs field name) + const analysisName = (analysis.b_name || "") + .toLowerCase() + .trim() + + if (analysisName) { + const fieldMatch = fields.find( + (field) => + field.b_name.toLowerCase().trim() === analysisName, + ) + + if (fieldMatch) { + if (matchedFieldId) { + // Check if it's the same field + if (matchedFieldId === fieldMatch.b_id) { + matchReason = "both" + } + } else { + matchedFieldId = fieldMatch.b_id + matchReason = "name" + } + } + } + + return { + ...analysis, + matchedFieldId, + matchReason, + } + }) +} diff --git a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx index 420b75fbd..a7befe1b7 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx @@ -27,9 +27,9 @@ import { type ProcessedAnalysis, } from "~/components/blocks/soil/bulk-upload-review" import { extractBulkSoilAnalyses } from "~/integrations/nmi" -import { booleanPointInPolygon } from "@turf/turf" import { Spinner } from "~/components/ui/spinner" import { redirectWithSuccess, dataWithSuccess } from "remix-toast" +import { matchAnalysesToFields } from "~/components/blocks/soil/bulk-upload-match" export async function loader({ request, params }: LoaderFunctionArgs) { try { @@ -80,63 +80,7 @@ export default function BulkSoilAnalysisUploadPage() { navigation.formMethod?.toLowerCase() === "post" const handleUploadSuccess = (analyses: any[]) => { - // Perform geometry matching - const matchedAnalyses = analyses.map((analysis) => { - let matchedFieldId = "" - let matchReason: "geometry" | "name" | "both" | undefined - - if (analysis.location && analysis.location.coordinates) { - const [lon, lat] = analysis.location.coordinates - if (typeof lon === "number" && typeof lat === "number") { - const fieldMatch = fields.find((field) => { - if (!field.geometry) return false - try { - return booleanPointInPolygon( - analysis.location, - field.geometry as any, - ) - } catch (e) { - console.warn(`Matching failed for field ${field.b_name}:`, e) - return false - } - }) - if (fieldMatch) { - matchedFieldId = fieldMatch.b_id - matchReason = "geometry" - } - } - } - - // If no geometry match, try name match (b_fieldname vs field name) - const analysisName = (analysis.b_name || "") - .toLowerCase() - .trim() - - if (analysisName) { - const fieldMatch = fields.find( - (field) => - field.b_name.toLowerCase().trim() === analysisName, - ) - - if (fieldMatch) { - if (matchedFieldId) { - if (matchedFieldId === fieldMatch.b_id) { - matchReason = "both" - } - } else { - matchedFieldId = fieldMatch.b_id - matchReason = "name" - } - } - } - - return { - ...analysis, - matchedFieldId, - matchReason, - } - }) - + const matchedAnalyses = matchAnalysesToFields(analyses, fields) setProcessedAnalyses(matchedAnalyses) setStep("review") } diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx index 024266cd0..77f4e468a 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx @@ -22,13 +22,13 @@ import { type ProcessedAnalysis, } from "~/components/blocks/soil/bulk-upload-review" import { extractBulkSoilAnalyses } from "~/integrations/nmi" -import { booleanPointInPolygon } from "@turf/turf" import { Header } from "~/components/blocks/header/base" import { HeaderFarmCreate } from "~/components/blocks/header/create-farm" import { SidebarInset } from "~/components/ui/sidebar" import { getCalendar, getTimeframe } from "~/lib/calendar" import { Spinner } from "~/components/ui/spinner" import { redirectWithSuccess, dataWithSuccess } from "remix-toast" +import { matchAnalysesToFields } from "~/components/blocks/soil/bulk-upload-match" export async function loader({ request, params }: LoaderFunctionArgs) { try { @@ -81,65 +81,7 @@ export default function BulkSoilAnalysisUploadWizardPage() { navigation.formMethod?.toLowerCase() === "post" const handleUploadSuccess = (analyses: any[]) => { - const matchedAnalyses = analyses.map((analysis) => { - let matchedFieldId = "" - let matchReason: "geometry" | "name" | "both" | undefined - - // Geometry matching - if (analysis.location && analysis.location.coordinates) { - const [lon, lat] = analysis.location.coordinates - if (typeof lon === "number" && typeof lat === "number") { - const fieldMatch = fields.find((field) => { - if (!field.geometry) return false - try { - // booleanPointInPolygon handles both Polygon and MultiPolygon - return booleanPointInPolygon( - analysis.location, - field.geometry as any, - ) - } catch (e) { - console.warn(`Matching failed for field ${field.b_name}:`, e) - return false - } - }) - if (fieldMatch) { - matchedFieldId = fieldMatch.b_id - matchReason = "geometry" - } - } - } - - // Fallback: Name matching (b_fieldname vs field name) - const analysisName = (analysis.b_name || "") - .toLowerCase() - .trim() - - if (analysisName) { - const fieldMatch = fields.find( - (field) => - field.b_name.toLowerCase().trim() === analysisName, - ) - - if (fieldMatch) { - if (matchedFieldId) { - // Check if it's the same field - if (matchedFieldId === fieldMatch.b_id) { - matchReason = "both" - } - } else { - matchedFieldId = fieldMatch.b_id - matchReason = "name" - } - } - } - - return { - ...analysis, - matchedFieldId, - matchReason, - } - }) - + const matchedAnalyses = matchAnalysesToFields(analyses, fields) setProcessedAnalyses(matchedAnalyses) setStep("review") } From 860c5da9e0257c56c40ff935a946e2674e1f92c0 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:03:19 +0100 Subject: [PATCH 20/23] fix: imports --- fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx index a7befe1b7..74ce86305 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx @@ -26,9 +26,8 @@ import { BulkSoilAnalysisReview, type ProcessedAnalysis, } from "~/components/blocks/soil/bulk-upload-review" -import { extractBulkSoilAnalyses } from "~/integrations/nmi" import { Spinner } from "~/components/ui/spinner" -import { redirectWithSuccess, dataWithSuccess } from "remix-toast" +import { redirectWithSuccess } from "remix-toast" import { matchAnalysesToFields } from "~/components/blocks/soil/bulk-upload-match" export async function loader({ request, params }: LoaderFunctionArgs) { From dc8e57db86533e196fe973124952c87d26fae5ce Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:03:41 +0100 Subject: [PATCH 21/23] fix: improve error message --- .../components/blocks/soil/bulk-upload-form.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx b/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx index 05fd0e56e..4a5a1f814 100644 --- a/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx +++ b/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx @@ -54,7 +54,19 @@ export function BulkSoilAnalysisUploadForm({ }) if (!response.ok) { - throw new Error("Fout bij starten van analyse") + let errorMessage = "Fout bij starten van analyse" + try { + const errorData = await response.json() + errorMessage = errorData.message || errorData.error || errorMessage + } catch { + try { + const textError = await response.text() + if (textError) errorMessage = textError + } catch { + // Ignore text parsing errors + } + } + throw new Error(`${errorMessage} (Status: ${response.status})`) } if (!response.body) { From 9cf0d268e1e0e6541f04d9d49437dc341f8da1af Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:04:33 +0100 Subject: [PATCH 22/23] refactor: strip raw data --- fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx | 1 + .../farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx index 74ce86305..8d7797ab2 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx @@ -210,6 +210,7 @@ export async function action({ request, params }: ActionFunctionArgs) { b_sampling_date, a_depth_upper, a_depth_lower, + data: _data, // Strip raw data ...dbAnalysis } = analysis diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx index 77f4e468a..87bc5fe8c 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx @@ -207,6 +207,7 @@ export async function action({ request, params }: ActionFunctionArgs) { b_sampling_date, a_depth_upper, a_depth_lower, + data: _data, // Strip raw data ...dbAnalysis } = analysis From 71fb6251bddc2a84e9ed747d2071992018794297 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:05:54 +0100 Subject: [PATCH 23/23] fix: use already defined type --- .../blocks/soil/bulk-upload-form.tsx | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx b/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx index 4a5a1f814..1a4d94003 100644 --- a/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx +++ b/fdm-app/app/components/blocks/soil/bulk-upload-form.tsx @@ -14,11 +14,12 @@ import { ScrollArea } from "~/components/ui/scroll-area" import { cn } from "~/lib/utils" import { Progress } from "~/components/ui/progress" import { toast } from "sonner" +import type { ProcessedAnalysis } from "./bulk-upload-review" export function BulkSoilAnalysisUploadForm({ onSuccess, }: { - onSuccess: (data: any[]) => void + onSuccess: (data: ProcessedAnalysis[]) => void }) { const [files, setFiles] = useState([]) const [isUploading, setIsUploading] = useState(false) @@ -57,7 +58,8 @@ export function BulkSoilAnalysisUploadForm({ let errorMessage = "Fout bij starten van analyse" try { const errorData = await response.json() - errorMessage = errorData.message || errorData.error || errorMessage + errorMessage = + errorData.message || errorData.error || errorMessage } catch { try { const textError = await response.text() @@ -95,7 +97,9 @@ export function BulkSoilAnalysisUploadForm({ if (result.success && result.analyses) { allResults.push(...result.analyses) } else if (result.error) { - toast.error(`Fout bij ${result.filename}: ${result.error}`) + toast.error( + `Fout bij ${result.filename}: ${result.error}`, + ) } setCurrentFile(result.filename) @@ -117,7 +121,9 @@ export function BulkSoilAnalysisUploadForm({ if (result.success && result.analyses) { allResults.push(...result.analyses) } else if (result.error) { - toast.error(`Fout bij ${result.filename}: ${result.error}`) + toast.error( + `Fout bij ${result.filename}: ${result.error}`, + ) } setCurrentFile(result.filename) @@ -130,14 +136,18 @@ export function BulkSoilAnalysisUploadForm({ } } catch (error) { console.error("Bulk upload error:", error) - toast.error(error instanceof Error ? error.message : "Upload mislukt") + toast.error( + error instanceof Error ? error.message : "Upload mislukt", + ) errorOccurred = true } finally { setIsUploading(false) setCurrentFile(null) if (allResults.length > 0) { - toast.success(`${allResults.length} analyses succesvol verwerkt`) + toast.success( + `${allResults.length} analyses succesvol verwerkt`, + ) onSuccess(allResults) } else if (!errorOccurred && totalFiles > 0) { toast.error("Geen analyses kunnen verwerken")