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" },
|
{ 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({
|
function NewImportModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -76,6 +203,12 @@ function NewImportModal({
|
|||||||
}) {
|
}) {
|
||||||
const [filePaths, setFilePaths] = useState<Record<string, string>>({});
|
const [filePaths, setFilePaths] = useState<Record<string, string>>({});
|
||||||
const [error, setError] = useState<string | null>(null);
|
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({
|
const stageMutation = trpc.dispo.stageImportBatch.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -83,30 +216,51 @@ function NewImportModal({
|
|||||||
onClose();
|
onClose();
|
||||||
setFilePaths({});
|
setFilePaths({});
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setValidateInput(null);
|
||||||
},
|
},
|
||||||
onError: (err) => setError(err.message),
|
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() {
|
function handleSubmit() {
|
||||||
setError(null);
|
setError(null);
|
||||||
const nonEmpty = Object.fromEntries(
|
const get = (key: string) => (filePaths[key] ?? "").trim();
|
||||||
Object.entries(filePaths).filter(([, v]) => v.trim().length > 0),
|
const referenceWorkbookPath = get("resources");
|
||||||
);
|
const planningWorkbookPath = get("projects");
|
||||||
if (Object.keys(nonEmpty).length === 0) {
|
const chargeabilityWorkbookPath = get("assignments");
|
||||||
|
const rosterWorkbookPath = get("vacations");
|
||||||
|
const costWorkbookPath = get("roles");
|
||||||
|
|
||||||
|
if (!referenceWorkbookPath && !planningWorkbookPath && !chargeabilityWorkbookPath) {
|
||||||
setError("Provide at least one workbook path.");
|
setError("Provide at least one workbook path.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stageMutation.mutate({
|
stageMutation.mutate({
|
||||||
referenceWorkbookPath: (nonEmpty as Record<string, string>).referenceWorkbookPath ?? "",
|
referenceWorkbookPath,
|
||||||
planningWorkbookPath: (nonEmpty as Record<string, string>).planningWorkbookPath ?? "",
|
planningWorkbookPath,
|
||||||
chargeabilityWorkbookPath: (nonEmpty as Record<string, string>).chargeabilityWorkbookPath ?? "",
|
chargeabilityWorkbookPath,
|
||||||
...(nonEmpty.rosterWorkbookPath ? { rosterWorkbookPath: nonEmpty.rosterWorkbookPath } : {}),
|
...(rosterWorkbookPath ? { rosterWorkbookPath } : {}),
|
||||||
...(nonEmpty.costWorkbookPath ? { costWorkbookPath: nonEmpty.costWorkbookPath } : {}),
|
...(costWorkbookPath ? { costWorkbookPath } : {}),
|
||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setValidateInput(null);
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="px-6 py-5">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||||
New Dispo Import
|
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"
|
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}
|
placeholder={placeholder}
|
||||||
value={filePaths[key] ?? ""}
|
value={filePaths[key] ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setFilePaths((prev) => ({ ...prev, [key]: e.target.value }))
|
setFilePaths((prev) => ({ ...prev, [key]: e.target.value }));
|
||||||
}
|
setValidateInput(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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 && (
|
{error && (
|
||||||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p>
|
<p className="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 mt-6">
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
<Button variant="secondary" onClick={onClose}>
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleValidate}
|
||||||
|
disabled={!hasRequiredPaths() || validateQuery.isFetching}
|
||||||
|
>
|
||||||
|
{validateQuery.isFetching ? "Validating…" : "Validate"}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={stageMutation.isPending}
|
disabled={stageMutation.isPending}
|
||||||
|
|||||||
Reference in New Issue
Block a user