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" }, { 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 = { type ReadinessReport = {
assignmentCount: number; assignmentCount: number;
availabilityRuleCount: number; availabilityRuleCount: number;
canCommitWithFallbacks: boolean; canCommitWithFallbacks: boolean;
canCommitWithStrictSourceData: boolean; canCommitWithStrictSourceData: boolean;
fallbackAssumptions: string[]; fallbackAssumptions: string[];
issues: { issues: ReadinessIssue[];
code: string;
count: number;
message: string;
resolution: string;
severity: "blocker" | "warning";
}[];
projectCount: number; projectCount: number;
resourceCount: number; resourceCount: number;
unresolvedCount: number; unresolvedCount: number;
vacationCount: 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 }) { function ReadinessReportPanel({ report }: { report: ReadinessReport }) {
const blockers = report.issues.filter((i) => i.severity === "blocker"); const blockers = report.issues.filter((i) => i.severity === "blocker");
const warnings = report.issues.filter((i) => i.severity === "warning"); const warnings = report.issues.filter((i) => i.severity === "warning");
return ( 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"> <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 ? ( {report.canCommitWithStrictSourceData ? (
<p className="text-sm font-medium text-green-700 dark:text-green-400"> <p className="text-sm font-medium text-green-700 dark:text-green-400">
Ready to stage no blockers found. Ready to stage no blockers found.
@@ -105,7 +145,6 @@ function ReadinessReportPanel({ report }: { report: ReadinessReport }) {
</p> </p>
)} )}
{/* Counts */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-gray-400"> <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.resourceCount} resources</span>
<span>{report.projectCount} projects</span> <span>{report.projectCount} projects</span>
@@ -118,51 +157,9 @@ function ReadinessReportPanel({ report }: { report: ReadinessReport }) {
)} )}
</div> </div>
{/* Blockers */} <IssueList issues={blockers} />
{blockers.length > 0 && ( <IssueList issues={warnings} />
<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 && ( {report.fallbackAssumptions.length > 0 && (
<div> <div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"> <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 [validateInput, setValidateInput] = useState<ReturnType<typeof buildValidateInput> | null>(null);
const validateQuery = trpc.dispo.validateImportBatch.useQuery( const validateQuery = trpc.dispo.validateImportBatch.useQuery(
validateInput ?? { referenceWorkbookPath: "", planningWorkbookPath: "", chargeabilityWorkbookPath: "" }, validateInput ?? EMPTY_VALIDATE_INPUT,
{ enabled: validateInput !== null, retry: false }, { enabled: validateInput !== null, retry: false },
); );
@@ -233,24 +230,12 @@ function NewImportModal({
function handleSubmit() { function handleSubmit() {
setError(null); setError(null);
const get = (key: string) => (filePaths[key] ?? "").trim(); const input = buildValidateInput(filePaths);
const referenceWorkbookPath = get("resources"); if (!input.referenceWorkbookPath && !input.planningWorkbookPath && !input.chargeabilityWorkbookPath) {
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."); setError("Provide at least one workbook path.");
return; return;
} }
stageMutation.mutate({ stageMutation.mutate(input as any);
referenceWorkbookPath,
planningWorkbookPath,
chargeabilityWorkbookPath,
...(rosterWorkbookPath ? { rosterWorkbookPath } : {}),
...(costWorkbookPath ? { costWorkbookPath } : {}),
} as any);
} }
function handleClose() { function handleClose() {
@@ -279,7 +264,7 @@ function NewImportModal({
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); if (validateInput !== null) setValidateInput(null);
}} }}
/> />
</div> </div>
@@ -291,7 +276,7 @@ function NewImportModal({
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">Validating</p> <p className="mt-4 text-sm text-gray-500 dark:text-gray-400">Validating</p>
)} )}
{!validateQuery.isFetching && validateQuery.data && ( {!validateQuery.isFetching && validateQuery.data && (
<ReadinessReportPanel report={validateQuery.data as ReadinessReport} /> <ReadinessReportPanel report={validateQuery.data} />
)} )}
{validateQuery.error && ( {validateQuery.error && (
<p className="mt-3 text-sm text-red-600 dark:text-red-400"> <p className="mt-3 text-sm text-red-600 dark:text-red-400">