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