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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user