From 8f464f2150844bb31349fe7380fe7011d2cfe390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 6 Apr 2026 09:32:23 +0200 Subject: [PATCH] feat(dispo): add dry-run Validate button to New Import modal Adds a "Validate" button that calls the existing `validateImportBatch` tRPC query before staging. Shows a readiness report inline: - Green/amber/red status line based on canCommitWithStrictSourceData - Record counts (resources, projects, assignments, vacations) - Blocker issues in red with resolution hints - Warnings in amber with resolution hints - Fallback assumptions listed in gray Also fixes a pre-existing bug where handleSubmit mapped wrong filePaths keys to API fields (keys are resources/projects/assignments, not the API field names). Co-Authored-By: Claude Sonnet 4.6 --- .../components/admin/DispoImportClient.tsx | 203 ++++++++++++++++-- 1 file changed, 189 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/admin/DispoImportClient.tsx b/apps/web/src/components/admin/DispoImportClient.tsx index 7182e39..1e78c74 100644 --- a/apps/web/src/components/admin/DispoImportClient.tsx +++ b/apps/web/src/components/admin/DispoImportClient.tsx @@ -65,6 +65,133 @@ const WORKBOOK_LABELS: { key: string; label: string; placeholder: string }[] = [ { key: "roles", label: "Roles Workbook", placeholder: "/data/dispo/roles.xlsx" }, ]; +type ReadinessReport = { + assignmentCount: number; + availabilityRuleCount: number; + canCommitWithFallbacks: boolean; + canCommitWithStrictSourceData: boolean; + fallbackAssumptions: string[]; + issues: { + code: string; + count: number; + message: string; + resolution: string; + severity: "blocker" | "warning"; + }[]; + projectCount: number; + resourceCount: number; + unresolvedCount: number; + vacationCount: number; +}; + +function ReadinessReportPanel({ report }: { report: ReadinessReport }) { + const blockers = report.issues.filter((i) => i.severity === "blocker"); + const warnings = report.issues.filter((i) => i.severity === "warning"); + + return ( +
+ {/* Status line */} + {report.canCommitWithStrictSourceData ? ( +

+ Ready to stage — no blockers found. +

+ ) : report.canCommitWithFallbacks ? ( +

+ Can stage with fallback assumptions applied. +

+ ) : ( +

+ Blockers present — resolve before staging. +

+ )} + + {/* Counts */} +
+ {report.resourceCount} resources + {report.projectCount} projects + {report.assignmentCount} assignments + {report.vacationCount} vacations + {report.unresolvedCount > 0 && ( + + {report.unresolvedCount} unresolved + + )} +
+ + {/* Blockers */} + {blockers.length > 0 && ( +
+ {blockers.map((issue) => ( +
+

+ {issue.message} + {issue.count > 1 && ( + ({issue.count}) + )} +

+

+ {issue.resolution} +

+
+ ))} +
+ )} + + {/* Warnings */} + {warnings.length > 0 && ( +
+ {warnings.map((issue) => ( +
+

+ {issue.message} + {issue.count > 1 && ( + ({issue.count}) + )} +

+

+ {issue.resolution} +

+
+ ))} +
+ )} + + {/* Fallback assumptions */} + {report.fallbackAssumptions.length > 0 && ( +
+

+ Fallback assumptions: +

+
    + {report.fallbackAssumptions.map((assumption, i) => ( +
  • + {assumption} +
  • + ))} +
+
+ )} +
+ ); +} + +function buildValidateInput(filePaths: Record) { + const get = (key: string) => filePaths[key]?.trim() ?? ""; + return { + referenceWorkbookPath: get("resources"), + planningWorkbookPath: get("projects"), + chargeabilityWorkbookPath: get("assignments"), + ...(get("vacations") ? { rosterWorkbookPath: get("vacations") } : {}), + ...(get("roles") ? { costWorkbookPath: get("roles") } : {}), + }; +} + function NewImportModal({ open, onClose, @@ -76,6 +203,12 @@ function NewImportModal({ }) { const [filePaths, setFilePaths] = useState>({}); const [error, setError] = useState(null); + const [validateInput, setValidateInput] = useState | null>(null); + + const validateQuery = trpc.dispo.validateImportBatch.useQuery( + validateInput ?? { referenceWorkbookPath: "", planningWorkbookPath: "", chargeabilityWorkbookPath: "" }, + { enabled: validateInput !== null, retry: false }, + ); const stageMutation = trpc.dispo.stageImportBatch.useMutation({ onSuccess: () => { @@ -83,30 +216,51 @@ function NewImportModal({ onClose(); setFilePaths({}); setError(null); + setValidateInput(null); }, onError: (err) => setError(err.message), }); + function hasRequiredPaths() { + const get = (key: string) => (filePaths[key] ?? "").trim(); + return get("resources").length > 0 && get("projects").length > 0 && get("assignments").length > 0; + } + + function handleValidate() { + setError(null); + setValidateInput(buildValidateInput(filePaths)); + } + function handleSubmit() { setError(null); - const nonEmpty = Object.fromEntries( - Object.entries(filePaths).filter(([, v]) => v.trim().length > 0), - ); - if (Object.keys(nonEmpty).length === 0) { + const get = (key: string) => (filePaths[key] ?? "").trim(); + const referenceWorkbookPath = get("resources"); + const planningWorkbookPath = get("projects"); + const chargeabilityWorkbookPath = get("assignments"); + const rosterWorkbookPath = get("vacations"); + const costWorkbookPath = get("roles"); + + if (!referenceWorkbookPath && !planningWorkbookPath && !chargeabilityWorkbookPath) { setError("Provide at least one workbook path."); return; } stageMutation.mutate({ - referenceWorkbookPath: (nonEmpty as Record).referenceWorkbookPath ?? "", - planningWorkbookPath: (nonEmpty as Record).planningWorkbookPath ?? "", - chargeabilityWorkbookPath: (nonEmpty as Record).chargeabilityWorkbookPath ?? "", - ...(nonEmpty.rosterWorkbookPath ? { rosterWorkbookPath: nonEmpty.rosterWorkbookPath } : {}), - ...(nonEmpty.costWorkbookPath ? { costWorkbookPath: nonEmpty.costWorkbookPath } : {}), + referenceWorkbookPath, + planningWorkbookPath, + chargeabilityWorkbookPath, + ...(rosterWorkbookPath ? { rosterWorkbookPath } : {}), + ...(costWorkbookPath ? { costWorkbookPath } : {}), } as any); } + function handleClose() { + setValidateInput(null); + setError(null); + onClose(); + } + return ( - +

New Dispo Import @@ -123,22 +277,43 @@ function NewImportModal({ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 focus:ring-2 focus:ring-brand-500 focus:border-brand-500" placeholder={placeholder} value={filePaths[key] ?? ""} - onChange={(e) => - setFilePaths((prev) => ({ ...prev, [key]: e.target.value })) - } + onChange={(e) => { + setFilePaths((prev) => ({ ...prev, [key]: e.target.value })); + setValidateInput(null); + }} />

))} + {/* Validation report */} + {validateQuery.isFetching && ( +

Validating…

+ )} + {!validateQuery.isFetching && validateQuery.data && ( + + )} + {validateQuery.error && ( +

+ Validation failed: {validateQuery.error.message} +

+ )} + {error && (

{error}

)}
- +