refactor(dispo): clean up validate UI in NewImportModal

- Extract EMPTY_VALIDATE_INPUT as module constant (prevents new object on every render)
- Extract IssueList component + ISSUE_STYLES map (eliminates blocker/warning copy-paste)
- Extract ReadinessIssue type from ReadinessReport
- Reuse buildValidateInput in handleSubmit (single source for path mapping)
- Guard setValidateInput(null) in onChange — only resets when not already null
- Remove unnecessary `as ReadinessReport` cast (tRPC infers the type)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 16:06:58 +02:00
parent 444fa70a19
commit 4accee95a4
@@ -65,32 +65,72 @@ const WORKBOOK_LABELS: { key: string; label: string; placeholder: string }[] = [
{ key: "roles", label: "Roles Workbook", placeholder: "/data/dispo/roles.xlsx" },
];
const EMPTY_VALIDATE_INPUT = {
referenceWorkbookPath: "",
planningWorkbookPath: "",
chargeabilityWorkbookPath: "",
} as const;
type ReadinessIssue = {
code: string;
count: number;
message: string;
resolution: string;
severity: "blocker" | "warning";
};
type ReadinessReport = {
assignmentCount: number;
availabilityRuleCount: number;
canCommitWithFallbacks: boolean;
canCommitWithStrictSourceData: boolean;
fallbackAssumptions: string[];
issues: {
code: string;
count: number;
message: string;
resolution: string;
severity: "blocker" | "warning";
}[];
issues: ReadinessIssue[];
projectCount: number;
resourceCount: number;
unresolvedCount: number;
vacationCount: number;
};
const ISSUE_STYLES = {
blocker: {
container: "rounded bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2",
title: "text-xs font-medium text-red-700 dark:text-red-400",
resolution: "text-xs text-red-600/80 dark:text-red-300/70 mt-0.5",
},
warning: {
container: "rounded bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 px-3 py-2",
title: "text-xs font-medium text-amber-700 dark:text-amber-400",
resolution: "text-xs text-amber-600/80 dark:text-amber-300/70 mt-0.5",
},
} as const;
function IssueList({ issues }: { issues: ReadinessIssue[] }) {
if (issues.length === 0) return null;
return (
<div className="space-y-1">
{issues.map((issue) => {
const s = ISSUE_STYLES[issue.severity];
return (
<div key={issue.code} className={s.container}>
<p className={s.title}>
{issue.message}
{issue.count > 1 && <span className="ml-1 font-normal">({issue.count})</span>}
</p>
<p className={s.resolution}>{issue.resolution}</p>
</div>
);
})}
</div>
);
}
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.
@@ -105,7 +145,6 @@ function ReadinessReportPanel({ report }: { report: ReadinessReport }) {
</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>
@@ -118,51 +157,9 @@ function ReadinessReportPanel({ report }: { report: ReadinessReport }) {
)}
</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>
)}
<IssueList issues={blockers} />
<IssueList issues={warnings} />
{/* 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">
@@ -206,7 +203,7 @@ function NewImportModal({
const [validateInput, setValidateInput] = useState<ReturnType<typeof buildValidateInput> | null>(null);
const validateQuery = trpc.dispo.validateImportBatch.useQuery(
validateInput ?? { referenceWorkbookPath: "", planningWorkbookPath: "", chargeabilityWorkbookPath: "" },
validateInput ?? EMPTY_VALIDATE_INPUT,
{ enabled: validateInput !== null, retry: false },
);
@@ -233,24 +230,12 @@ function NewImportModal({
function handleSubmit() {
setError(null);
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) {
const input = buildValidateInput(filePaths);
if (!input.referenceWorkbookPath && !input.planningWorkbookPath && !input.chargeabilityWorkbookPath) {
setError("Provide at least one workbook path.");
return;
}
stageMutation.mutate({
referenceWorkbookPath,
planningWorkbookPath,
chargeabilityWorkbookPath,
...(rosterWorkbookPath ? { rosterWorkbookPath } : {}),
...(costWorkbookPath ? { costWorkbookPath } : {}),
} as any);
stageMutation.mutate(input as any);
}
function handleClose() {
@@ -279,7 +264,7 @@ function NewImportModal({
value={filePaths[key] ?? ""}
onChange={(e) => {
setFilePaths((prev) => ({ ...prev, [key]: e.target.value }));
setValidateInput(null);
if (validateInput !== null) setValidateInput(null);
}}
/>
</div>
@@ -291,7 +276,7 @@ function NewImportModal({
<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} />
<ReadinessReportPanel report={validateQuery.data} />
)}
{validateQuery.error && (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">