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" },
|
{ 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user