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 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 09:32:23 +02:00
parent bdb55f23d3
commit 8f464f2150
@@ -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 (
<div className="mt-4 rounded-lg border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50 p-4 space-y-3">
{/* Status line */}
{report.canCommitWithStrictSourceData ? (
<p className="text-sm font-medium text-green-700 dark:text-green-400">
Ready to stage no blockers found.
</p>
) : report.canCommitWithFallbacks ? (
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
Can stage with fallback assumptions applied.
</p>
) : (
<p className="text-sm font-medium text-red-700 dark:text-red-400">
Blockers present resolve before staging.
</p>
)}
{/* Counts */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-gray-400">
<span>{report.resourceCount} resources</span>
<span>{report.projectCount} projects</span>
<span>{report.assignmentCount} assignments</span>
<span>{report.vacationCount} vacations</span>
{report.unresolvedCount > 0 && (
<span className="text-amber-600 dark:text-amber-400 font-medium">
{report.unresolvedCount} unresolved
</span>
)}
</div>
{/* Blockers */}
{blockers.length > 0 && (
<div className="space-y-1">
{blockers.map((issue) => (
<div
key={issue.code}
className="rounded bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2"
>
<p className="text-xs font-medium text-red-700 dark:text-red-400">
{issue.message}
{issue.count > 1 && (
<span className="ml-1 font-normal">({issue.count})</span>
)}
</p>
<p className="text-xs text-red-600/80 dark:text-red-300/70 mt-0.5">
{issue.resolution}
</p>
</div>
))}
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="space-y-1">
{warnings.map((issue) => (
<div
key={issue.code}
className="rounded bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 px-3 py-2"
>
<p className="text-xs font-medium text-amber-700 dark:text-amber-400">
{issue.message}
{issue.count > 1 && (
<span className="ml-1 font-normal">({issue.count})</span>
)}
</p>
<p className="text-xs text-amber-600/80 dark:text-amber-300/70 mt-0.5">
{issue.resolution}
</p>
</div>
))}
</div>
)}
{/* Fallback assumptions */}
{report.fallbackAssumptions.length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Fallback assumptions:
</p>
<ul className="space-y-0.5">
{report.fallbackAssumptions.map((assumption, i) => (
<li key={i} className="text-xs text-gray-500 dark:text-gray-400 pl-2 border-l-2 border-gray-300 dark:border-gray-600">
{assumption}
</li>
))}
</ul>
</div>
)}
</div>
);
}
function buildValidateInput(filePaths: Record<string, string>) {
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<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [validateInput, setValidateInput] = useState<ReturnType<typeof buildValidateInput> | 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<string, string>).referenceWorkbookPath ?? "",
planningWorkbookPath: (nonEmpty as Record<string, string>).planningWorkbookPath ?? "",
chargeabilityWorkbookPath: (nonEmpty as Record<string, string>).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 (
<AnimatedModal open={open} onClose={onClose} maxWidth="max-w-lg">
<AnimatedModal open={open} onClose={handleClose} maxWidth="max-w-lg">
<div className="px-6 py-5">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
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);
}}
/>
</div>
))}
</div>
{/* Validation report */}
{validateQuery.isFetching && (
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">Validating</p>
)}
{!validateQuery.isFetching && validateQuery.data && (
<ReadinessReportPanel report={validateQuery.data as ReadinessReport} />
)}
{validateQuery.error && (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
Validation failed: {validateQuery.error.message}
</p>
)}
{error && (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<div className="flex justify-end gap-3 mt-6">
<Button variant="secondary" onClick={onClose}>
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
<Button
variant="secondary"
onClick={handleValidate}
disabled={!hasRequiredPaths() || validateQuery.isFetching}
>
{validateQuery.isFetching ? "Validating…" : "Validate"}
</Button>
<Button
onClick={handleSubmit}
disabled={stageMutation.isPending}