Files
Nexus/apps/web/src/components/admin/DispoImportDetailClient.tsx
T
Hartmut 7e4b21afe9 feat: Dispo V2 import — API router + admin UI
API Router (packages/api/src/router/dispo.ts):
- 12 adminProcedure endpoints wired to existing application layer
- stageImportBatch, validateImportBatch, commitImportBatch
- listImportBatches, getImportBatch, cancelImportBatch
- listStagedResources/Projects/Assignments/Vacations/Unresolved
- resolveStagedRecord (APPROVE/REJECT/SKIP actions)

Admin UI:
- /admin/dispo-imports — batch list with status filter, new import modal
- /admin/dispo-imports/[batchId] — detail with 6 tabs:
  Summary, Resources, Projects, Assignments, Vacations, Unresolved
- Unresolved review queue with approve/skip per-record actions
- Commit workflow with pre-validation and progress indicator
- Sidebar nav link under Admin

Also fixes: timeline filter dropdown z-index (toolbar relative z-20)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-22 19:07:20 +01:00

1120 lines
40 KiB
TypeScript

"use client";
import { useState } from "react";
import Link from "next/link";
import { clsx } from "clsx";
import { Button } from "@planarchy/ui";
import { Badge } from "@planarchy/ui";
import { trpc } from "~/lib/trpc/client.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { ShimmerSkeleton } from "~/components/ui/ShimmerSkeleton.js";
/* ------------------------------------------------------------------ */
/* Shared types (mirrors API) */
/* ------------------------------------------------------------------ */
type BatchStatus =
| "DRAFT"
| "STAGING"
| "STAGED"
| "REVIEW_READY"
| "APPROVED"
| "COMMITTING"
| "COMMITTED"
| "FAILED"
| "CANCELLED";
type RecordStatus = "PARSED" | "NORMALIZED" | "UNRESOLVED" | "APPROVED" | "REJECTED" | "COMMITTED" | "FAILED";
type UnresolvedAction = "APPROVE" | "REJECT" | "SKIP";
const STATUS_BADGE: Record<
BatchStatus,
{ label: string; variant: "default" | "success" | "warning" | "danger" | "info" }
> = {
DRAFT: { label: "Draft", variant: "default" },
STAGING: { label: "Staging", variant: "info" },
STAGED: { label: "Staged", variant: "info" },
REVIEW_READY: { label: "Review Ready", variant: "warning" },
APPROVED: { label: "Approved", variant: "success" },
COMMITTING: { label: "Committing", variant: "info" },
COMMITTED: { label: "Committed", variant: "success" },
FAILED: { label: "Failed", variant: "danger" },
CANCELLED: { label: "Cancelled", variant: "default" },
};
const RECORD_STATUS_COLORS: Record<RecordStatus, string> = {
PARSED: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400",
NORMALIZED: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
UNRESOLVED: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400",
APPROVED: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400",
REJECTED: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400",
COMMITTED: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400",
FAILED: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400",
};
type TabKey = "summary" | "resources" | "projects" | "assignments" | "vacations" | "unresolved";
const TABS: { key: TabKey; label: string }[] = [
{ key: "summary", label: "Summary" },
{ key: "resources", label: "Resources" },
{ key: "projects", label: "Projects" },
{ key: "assignments", label: "Assignments" },
{ key: "vacations", label: "Vacations" },
{ key: "unresolved", label: "Unresolved" },
];
/* ------------------------------------------------------------------ */
/* Helper */
/* ------------------------------------------------------------------ */
function RecordBadge({ status }: { status: RecordStatus }) {
return (
<span
className={clsx(
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
RECORD_STATUS_COLORS[status] ?? RECORD_STATUS_COLORS.PARSED,
)}
>
{status}
</span>
);
}
function formatDate(d: string | null | undefined) {
if (!d) return "-";
return new Date(d).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function SummaryCard({
label,
count,
variant = "default",
}: {
label: string;
count: number;
variant?: "default" | "warning" | "danger";
}) {
return (
<div
className={clsx(
"rounded-xl border p-4",
variant === "danger"
? "border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20"
: variant === "warning"
? "border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900/20"
: "border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800",
)}
>
<p className="text-sm text-gray-500 dark:text-gray-400">{label}</p>
<p
className={clsx(
"text-2xl font-bold mt-1",
variant === "danger"
? "text-red-700 dark:text-red-400"
: variant === "warning"
? "text-amber-700 dark:text-amber-400"
: "text-gray-900 dark:text-gray-100",
)}
>
{count}
</p>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Tab: Summary */
/* ------------------------------------------------------------------ */
function SummaryTab({
batch,
validationResult,
}: {
batch: {
counts: {
resourceCount: number;
clientCount: number;
projectCount: number;
assignmentCount: number;
vacationCount: number;
availabilityRuleCount: number;
unresolvedCount: number;
} | null;
status: BatchStatus;
};
validationResult: { blocking: string[]; warnings: string[] } | null;
}) {
const counts = batch.counts;
return (
<div className="space-y-6">
{/* Counts grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
<SummaryCard label="Resources" count={counts?.resourceCount ?? 0} />
<SummaryCard label="Projects" count={counts?.projectCount ?? 0} />
<SummaryCard label="Assignments" count={counts?.assignmentCount ?? 0} />
<SummaryCard label="Vacations" count={counts?.vacationCount ?? 0} />
<SummaryCard
label="Unresolved"
count={counts?.unresolvedCount ?? 0}
variant={
(counts?.unresolvedCount ?? 0) > 0 ? "warning" : "default"
}
/>
</div>
{/* Validation results */}
{validationResult && (
<div className="space-y-4">
{validationResult.blocking.length > 0 && (
<div className="rounded-xl border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-4">
<h3 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-2">
Blocking Issues ({validationResult.blocking.length})
</h3>
<ul className="list-disc list-inside text-sm text-red-600 dark:text-red-400 space-y-1">
{validationResult.blocking.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</div>
)}
{validationResult.warnings.length > 0 && (
<div className="rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900/20 p-4">
<h3 className="text-sm font-semibold text-amber-700 dark:text-amber-400 mb-2">
Warnings ({validationResult.warnings.length})
</h3>
<ul className="list-disc list-inside text-sm text-amber-600 dark:text-amber-400 space-y-1">
{validationResult.warnings.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</div>
)}
{validationResult.blocking.length === 0 &&
validationResult.warnings.length === 0 && (
<div className="rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-900/20 p-4">
<p className="text-sm font-medium text-green-700 dark:text-green-400">
All validation checks passed. Ready to commit.
</p>
</div>
)}
</div>
)}
{/* Status indicator for failed batches */}
{batch.status === "FAILED" && (
<div className="rounded-xl border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-4">
<h3 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-1">
Error
</h3>
<p className="text-sm text-red-600 dark:text-red-400 font-mono whitespace-pre-wrap">
This import batch has failed. Check the unresolved records tab for details.
</p>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Tab: Resources */
/* ------------------------------------------------------------------ */
function ResourcesTab({ batchId }: { batchId: string }) {
const [page, setPage] = useState(0);
const PAGE_SIZE = 50;
const { data: rawData, isLoading } = trpc.dispo.listStagedResources.useQuery(
{ importBatchId: batchId, limit: PAGE_SIZE },
{ staleTime: 15_000 },
);
const data = rawData as { items: Record<string, unknown>[]; nextCursor?: string } | undefined;
if (isLoading) return <TableSkeleton rows={8} cols={5} />;
const records = data?.items ?? [];
const total = records.length;
return (
<div>
<PaginatedTable
total={total}
page={page}
pageSize={PAGE_SIZE}
onPageChange={setPage}
>
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800/60">
<tr>
<Th>Name</Th>
<Th>EID</Th>
<Th>Chapter</Th>
<Th>Status</Th>
<Th>Warnings</Th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700/50">
{records.map((r: Record<string, unknown>) => (
<tr key={r.id as string} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<Td>{(r.displayName as string) ?? (r.name as string) ?? "-"}</Td>
<Td mono>{(r.eid as string) ?? "-"}</Td>
<Td>{(r.chapter as string) ?? "-"}</Td>
<Td>
<RecordBadge status={(r.status as RecordStatus) ?? "PARSED"} />
</Td>
<Td>
{Array.isArray(r.warnings) && r.warnings.length > 0 ? (
<span className="text-amber-600 dark:text-amber-400 text-xs">
{r.warnings.length} warning{r.warnings.length !== 1 ? "s" : ""}
</span>
) : (
<span className="text-gray-400 text-xs">-</span>
)}
</Td>
</tr>
))}
{records.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-sm text-gray-400">
No staged resources.
</td>
</tr>
)}
</tbody>
</table>
</PaginatedTable>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Tab: Projects */
/* ------------------------------------------------------------------ */
function ProjectsTab({ batchId }: { batchId: string }) {
const [page, setPage] = useState(0);
const PAGE_SIZE = 50;
const { data: rawData, isLoading } = trpc.dispo.listStagedProjects.useQuery(
{ importBatchId: batchId, limit: PAGE_SIZE } as { importBatchId: string; limit: number },
{ staleTime: 15_000 },
);
const data = rawData as { items: Record<string, unknown>[]; nextCursor?: string } | undefined;
if (isLoading) return <TableSkeleton rows={8} cols={6} />;
const records = data?.items ?? [];
const total = records.length;
return (
<PaginatedTable total={total} page={page} pageSize={PAGE_SIZE} onPageChange={setPage}>
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800/60">
<tr>
<Th>Project Key</Th>
<Th>Name</Th>
<Th>Client</Th>
<Th>TBD</Th>
<Th>Status</Th>
<Th>Warnings</Th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700/50">
{records.map((r) => (
<tr key={r.id as string} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<Td mono>{(r.projectKey as string) ?? "-"}</Td>
<Td>{(r.name as string) ?? "-"}</Td>
<Td>{(r.clientName as string) ?? "-"}</Td>
<Td>
{r.isTbd ? (
<Badge variant="warning">TBD</Badge>
) : null}
</Td>
<Td>
<RecordBadge status={(r.status as RecordStatus) ?? "PARSED"} />
</Td>
<Td>
{Array.isArray(r.warnings) && r.warnings.length > 0 ? (
<span className="text-amber-600 dark:text-amber-400 text-xs">
{r.warnings.length}
</span>
) : (
<span className="text-gray-400 text-xs">-</span>
)}
</Td>
</tr>
))}
{records.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-gray-400">
No staged projects.
</td>
</tr>
)}
</tbody>
</table>
</PaginatedTable>
);
}
/* ------------------------------------------------------------------ */
/* Tab: Assignments */
/* ------------------------------------------------------------------ */
function AssignmentsTab({ batchId }: { batchId: string }) {
const [page, setPage] = useState(0);
const PAGE_SIZE = 50;
const { data: rawData, isLoading } = trpc.dispo.listStagedAssignments.useQuery(
{ importBatchId: batchId, limit: PAGE_SIZE },
{ staleTime: 15_000 },
);
const data = rawData as { items: Record<string, unknown>[]; nextCursor?: string } | undefined;
if (isLoading) return <TableSkeleton rows={8} cols={6} />;
const records = data?.items ?? [];
const total = records.length;
return (
<PaginatedTable total={total} page={page} pageSize={PAGE_SIZE} onPageChange={setPage}>
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800/60">
<tr>
<Th>Resource</Th>
<Th>Project</Th>
<Th>Date</Th>
<Th>Hours</Th>
<Th>Role</Th>
<Th>Status</Th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700/50">
{records.map((r: Record<string, unknown>, idx: number) => (
<tr key={(r.id as string) ?? idx} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<Td>{(r.resourceName as string) ?? (r.resourceEid as string) ?? "-"}</Td>
<Td>{(r.projectName as string) ?? (r.projectKey as string) ?? "-"}</Td>
<Td mono>{(r.date as string) ?? "-"}</Td>
<Td>{r.hours != null ? String(r.hours) : "-"}</Td>
<Td>{(r.roleName as string) ?? "-"}</Td>
<Td>
<RecordBadge status={(r.status as RecordStatus) ?? "PARSED"} />
</Td>
</tr>
))}
{records.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-gray-400">
No staged assignments.
</td>
</tr>
)}
</tbody>
</table>
</PaginatedTable>
);
}
/* ------------------------------------------------------------------ */
/* Tab: Vacations */
/* ------------------------------------------------------------------ */
function VacationsTab({ batchId }: { batchId: string }) {
const [page, setPage] = useState(0);
const PAGE_SIZE = 50;
const { data: rawData, isLoading } = trpc.dispo.listStagedVacations.useQuery(
{ importBatchId: batchId, limit: PAGE_SIZE },
{ staleTime: 15_000 },
);
const data = rawData as { items: Record<string, unknown>[]; nextCursor?: string } | undefined;
if (isLoading) return <TableSkeleton rows={8} cols={5} />;
const records = data?.items ?? [];
const total = records.length;
return (
<PaginatedTable total={total} page={page} pageSize={PAGE_SIZE} onPageChange={setPage}>
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800/60">
<tr>
<Th>Resource</Th>
<Th>Start</Th>
<Th>End</Th>
<Th>Type</Th>
<Th>Status</Th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700/50">
{records.map((r: Record<string, unknown>, idx: number) => (
<tr key={(r.id as string) ?? idx} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<Td>{(r.resourceName as string) ?? (r.resourceEid as string) ?? "-"}</Td>
<Td mono>{(r.startDate as string) ?? "-"}</Td>
<Td mono>{(r.endDate as string) ?? "-"}</Td>
<Td>{(r.vacationType as string) ?? "-"}</Td>
<Td>
<RecordBadge status={(r.status as RecordStatus) ?? "PARSED"} />
</Td>
</tr>
))}
{records.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-sm text-gray-400">
No staged vacations.
</td>
</tr>
)}
</tbody>
</table>
</PaginatedTable>
);
}
/* ------------------------------------------------------------------ */
/* Tab: Unresolved Review Queue */
/* ------------------------------------------------------------------ */
function UnresolvedTab({ batchId }: { batchId: string }) {
const utils = trpc.useUtils();
const { data: rawData, isLoading, refetch } = trpc.dispo.listStagedUnresolvedRecords.useQuery(
{ importBatchId: batchId },
{ staleTime: 10_000 },
);
const data = rawData as { items: Record<string, unknown>[]; nextCursor?: string } | undefined;
const resolveMutation = trpc.dispo.resolveStagedRecord.useMutation({
onSuccess: () => {
refetch();
utils.dispo.getImportBatch.invalidate({ id: batchId });
},
});
const records = data?.items ?? [];
const remaining = records.filter(
(r) => r.status === "PARSED" || r.status === "NORMALIZED" || r.status === "UNRESOLVED",
).length;
function handleAction(recordId: string, action: UnresolvedAction) {
resolveMutation.mutate({
id: recordId,
recordType: "UNRESOLVED" as const,
action,
});
}
function handleBulkApproveNonBlocking() {
const nonBlocking = records.filter(
(r: Record<string, unknown>) =>
r.status === "PARSED" || r.status === "NORMALIZED" || r.status === "UNRESOLVED",
);
for (const r of nonBlocking) {
handleAction(r.id as string, "APPROVE");
}
}
function handleBulkSkipTbd() {
const tbdRecords = records.filter(
(r: Record<string, unknown>) =>
(r.status === "PARSED" || r.status === "NORMALIZED" || r.status === "UNRESOLVED") &&
typeof r.message === "string" &&
(r.message as string).toLowerCase().includes("[tbd]"),
);
for (const r of tbdRecords) {
handleAction(r.id as string, "SKIP");
}
}
if (isLoading) return <TableSkeleton rows={6} cols={5} />;
return (
<div className="space-y-4">
{/* Header with bulk actions */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
Unresolved Records
</h3>
{remaining > 0 && (
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400 text-xs font-bold">
{remaining}
</span>
)}
</div>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={handleBulkSkipTbd}
disabled={resolveMutation.isPending}
>
Skip All [tbd]
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleBulkApproveNonBlocking}
disabled={resolveMutation.isPending}
>
Approve All Non-Blocking
</Button>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800/60">
<tr>
<Th>Type</Th>
<Th>Message</Th>
<Th>Source</Th>
<Th>Hint</Th>
<Th>Actions</Th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700/50">
{records.map((r: Record<string, unknown>) => {
const resolved = r.status === "APPROVED" || r.status === "REJECTED";
return (
<tr
key={r.id as string}
className={clsx(
"transition-colors",
resolved
? "opacity-50 bg-gray-50 dark:bg-gray-800/30"
: "hover:bg-gray-50 dark:hover:bg-gray-700/30",
)}
>
<Td>
<Badge
variant="warning"
>
{(r.recordType as string) ?? "unknown"}
</Badge>
</Td>
<Td>
<span className="text-sm text-gray-700 dark:text-gray-300">
{(r.message as string) ?? "-"}
</span>
</Td>
<Td>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{(r.source as string) ?? "-"}
</span>
</Td>
<Td>
<span className="text-xs text-gray-500 dark:text-gray-400 italic">
{(r.hint as string) ?? "-"}
</span>
</Td>
<Td>
{resolved ? (
<span className="text-xs text-gray-400 italic">
{r.status as string}
</span>
) : (
<div className="flex gap-1.5">
<button
type="button"
onClick={() => handleAction(r.id as string, "APPROVE")}
disabled={resolveMutation.isPending}
className="px-2 py-1 text-xs font-medium rounded-md bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/40 dark:text-green-400 dark:hover:bg-green-900/60 transition-colors disabled:opacity-50"
>
Approve
</button>
<button
type="button"
onClick={() => handleAction(r.id as string, "REJECT")}
disabled={resolveMutation.isPending}
className="px-2 py-1 text-xs font-medium rounded-md bg-red-100 text-red-600 hover:bg-red-200 dark:bg-red-900/40 dark:text-red-400 dark:hover:bg-red-900/60 transition-colors disabled:opacity-50"
>
Reject
</button>
<button
type="button"
onClick={() => handleAction(r.id as string, "SKIP")}
disabled={resolveMutation.isPending}
className="px-2 py-1 text-xs font-medium rounded-md bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
>
Skip
</button>
</div>
)}
</Td>
</tr>
);
})}
{records.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-sm text-gray-400">
No unresolved records. Ready to commit.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Commit Confirmation Modal */
/* ------------------------------------------------------------------ */
function CommitModal({
open,
onClose,
batchId,
counts,
validationResult,
onCommitted,
}: {
open: boolean;
onClose: () => void;
batchId: string;
counts: {
resourceCount: number;
clientCount: number;
projectCount: number;
assignmentCount: number;
vacationCount: number;
availabilityRuleCount: number;
unresolvedCount: number;
} | null;
validationResult: { blocking: string[]; warnings: string[] } | null;
onCommitted: () => void;
}) {
const commitMutation = trpc.dispo.commitImportBatch.useMutation({
onSuccess: () => {
onCommitted();
onClose();
},
});
const hasBlockers = (validationResult?.blocking.length ?? 0) > 0;
return (
<AnimatedModal open={open} onClose={onClose} maxWidth="max-w-lg">
<div className="px-6 py-5">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Commit Import
</h2>
{/* Validation results */}
{validationResult && validationResult.blocking.length > 0 && (
<div className="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-3 mb-4">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">
Blocking Issues
</p>
<ul className="list-disc list-inside text-xs text-red-600 dark:text-red-400 space-y-0.5">
{validationResult.blocking.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</div>
)}
{validationResult && validationResult.warnings.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900/20 p-3 mb-4">
<p className="text-sm font-medium text-amber-700 dark:text-amber-400 mb-1">
Warnings
</p>
<ul className="list-disc list-inside text-xs text-amber-600 dark:text-amber-400 space-y-0.5">
{validationResult.warnings.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</div>
)}
{/* Entity counts */}
{counts && (
<div className="rounded-lg border border-gray-200 dark:border-gray-700 p-3 mb-4">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Will be committed:
</p>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-gray-600 dark:text-gray-400">
Resources: <strong className="text-gray-900 dark:text-gray-100">{counts.resourceCount}</strong>
</div>
<div className="text-gray-600 dark:text-gray-400">
Projects: <strong className="text-gray-900 dark:text-gray-100">{counts.projectCount}</strong>
</div>
<div className="text-gray-600 dark:text-gray-400">
Assignments: <strong className="text-gray-900 dark:text-gray-100">{counts.assignmentCount}</strong>
</div>
<div className="text-gray-600 dark:text-gray-400">
Vacations: <strong className="text-gray-900 dark:text-gray-100">{counts.vacationCount}</strong>
</div>
</div>
</div>
)}
{/* Progress during commit */}
{commitMutation.isPending && (
<div className="mb-4">
<div className="flex items-center gap-3">
<div className="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div className="h-full bg-brand-600 rounded-full animate-pulse w-2/3" />
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">Committing...</span>
</div>
</div>
)}
{commitMutation.error && (
<p className="text-sm text-red-600 dark:text-red-400 mb-4">
{commitMutation.error.message}
</p>
)}
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose} disabled={commitMutation.isPending}>
Cancel
</Button>
<Button
variant="danger"
onClick={() => commitMutation.mutate({ importBatchId: batchId })}
disabled={hasBlockers || commitMutation.isPending}
>
{commitMutation.isPending ? "Committing..." : "Commit Import"}
</Button>
</div>
</div>
</AnimatedModal>
);
}
/* ------------------------------------------------------------------ */
/* Shared Table Primitives */
/* ------------------------------------------------------------------ */
function Th({ children }: { children: React.ReactNode }) {
return (
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{children}
</th>
);
}
function Td({ children, mono }: { children: React.ReactNode; mono?: boolean }) {
return (
<td
className={clsx(
"px-4 py-3 text-sm text-gray-700 dark:text-gray-300",
mono && "font-mono",
)}
>
{children}
</td>
);
}
function TableSkeleton({ rows, cols }: { rows: number; cols: number }) {
return (
<div className="space-y-2">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex gap-4">
{Array.from({ length: cols }).map((_, j) => (
<ShimmerSkeleton key={j} height={20} rounded="md" className="flex-1" />
))}
</div>
))}
</div>
);
}
function PaginatedTable({
total,
page,
pageSize,
onPageChange,
children,
}: {
total: number;
page: number;
pageSize: number;
onPageChange: (p: number) => void;
children: React.ReactNode;
}) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div>
<div className="overflow-x-auto rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
{children}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-3 px-1">
<p className="text-xs text-gray-500 dark:text-gray-400">
Showing {page * pageSize + 1}-{Math.min((page + 1) * pageSize, total)} of {total}
</p>
<div className="flex gap-1">
<button
type="button"
disabled={page === 0}
onClick={() => onPageChange(page - 1)}
className="px-2.5 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Prev
</button>
<span className="px-2.5 py-1 text-xs text-gray-500 dark:text-gray-400">
{page + 1} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages - 1}
onClick={() => onPageChange(page + 1)}
className="px-2.5 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Main Component */
/* ------------------------------------------------------------------ */
export function DispoImportDetailClient({ batchId }: { batchId: string }) {
const [activeTab, setActiveTab] = useState<TabKey>("summary");
const [showCommitModal, setShowCommitModal] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [validationResult, setValidationResult] = useState<{
blocking: string[];
warnings: string[];
} | null>(null);
const utils = trpc.useUtils();
const { data: batch, isLoading } = trpc.dispo.getImportBatch.useQuery(
{ id: batchId },
{ staleTime: 5_000 },
);
// validateImportBatch is a query, so we use a manual trigger pattern
const [validationInput, setValidationInput] = useState<{
chargeabilityWorkbookPath: string;
planningWorkbookPath: string;
referenceWorkbookPath: string;
importBatchId: string;
} | null>(null);
const { isFetching: isValidating } = trpc.dispo.validateImportBatch.useQuery(
validationInput!,
{
enabled: validationInput !== null,
retry: false,
onSuccess: (result: { blocking: string[]; warnings: string[] }) => {
setValidationResult(result);
utils.dispo.getImportBatch.invalidate({ id: batchId });
setValidationInput(null);
},
onError: () => {
setValidationInput(null);
},
} as any,
);
const cancelMutation = trpc.dispo.cancelImportBatch.useMutation({
onSuccess: () => {
setShowCancelConfirm(false);
utils.dispo.getImportBatch.invalidate({ id: batchId });
},
});
const status = (batch?.status as BatchStatus) ?? "DRAFT";
const counts = batch?.counts ?? null;
const canReview = status === "STAGED" || status === "REVIEW_READY";
const canValidate = canReview;
const canCommit = canReview && (validationResult ? validationResult.blocking.length === 0 : false);
const canCancel = status === "DRAFT" || status === "STAGED" || status === "REVIEW_READY";
const isReadOnly = status === "COMMITTED" || status === "CANCELLED" || status === "FAILED";
if (isLoading) {
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
<ShimmerSkeleton height={40} rounded="lg" />
<div className="grid grid-cols-5 gap-4">
{Array.from({ length: 5 }).map((_, i) => (
<ShimmerSkeleton key={i} height={80} rounded="xl" />
))}
</div>
<ShimmerSkeleton height={300} rounded="xl" />
</div>
);
}
if (!batch) {
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center">
<p className="text-gray-500 dark:text-gray-400">Batch not found.</p>
<Link
href="/admin/dispo-imports"
className="text-sm text-brand-600 hover:text-brand-800 dark:text-brand-400 mt-2 inline-block"
>
Back to list
</Link>
</div>
</div>
);
}
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
{/* Back link */}
<Link
href="/admin/dispo-imports"
className="text-sm text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300 mb-4 inline-flex items-center gap-1"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
All Imports
</Link>
{/* Header */}
<div className="flex items-start justify-between mb-6 mt-2">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Import Batch
</h1>
<Badge variant={STATUS_BADGE[status]?.variant ?? "default"}>
{STATUS_BADGE[status]?.label ?? status}
</Badge>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-1">
{batchId}
</p>
<div className="flex gap-4 text-xs text-gray-500 dark:text-gray-400 mt-2">
<span>Created: {formatDate(batch.createdAt as string)}</span>
<span>Updated: {formatDate(batch.updatedAt as string)}</span>
{batch.committedAt && (
<span>Committed: {formatDate(batch.committedAt as string)}</span>
)}
</div>
</div>
{/* Action buttons */}
<div className="flex gap-2">
{canValidate && (
<Button
variant="secondary"
size="sm"
onClick={() => {
setValidationInput({
chargeabilityWorkbookPath: (batch as any)?.chargeabilitySourceFile ?? "",
planningWorkbookPath: (batch as any)?.planningSourceFile ?? "",
referenceWorkbookPath: (batch as any)?.referenceSourceFile ?? "",
importBatchId: batchId,
});
}}
disabled={isValidating}
>
{isValidating ? "Validating..." : "Validate"}
</Button>
)}
{canReview && (
<Button
size="sm"
onClick={() => setShowCommitModal(true)}
disabled={!canCommit && validationResult !== null}
>
Commit
</Button>
)}
{canCancel && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowCancelConfirm(true)}
>
Cancel Import
</Button>
)}
</div>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700 mb-6">
<nav className="flex gap-0 -mb-px">
{TABS.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={clsx(
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
activeTab === tab.key
? "border-brand-600 text-brand-700 dark:text-brand-400 dark:border-brand-400"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600",
)}
>
{tab.label}
{tab.key === "unresolved" && (counts?.unresolvedCount ?? 0) > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center min-w-[18px] h-4 px-1 rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400 text-[10px] font-bold">
{counts!.unresolvedCount}
</span>
)}
</button>
))}
</nav>
</div>
{/* Tab content */}
<div>
{activeTab === "summary" && (
<SummaryTab batch={{ ...batch, status, counts }} validationResult={validationResult} />
)}
{activeTab === "resources" && <ResourcesTab batchId={batchId} />}
{activeTab === "projects" && <ProjectsTab batchId={batchId} />}
{activeTab === "assignments" && <AssignmentsTab batchId={batchId} />}
{activeTab === "vacations" && <VacationsTab batchId={batchId} />}
{activeTab === "unresolved" && <UnresolvedTab batchId={batchId} />}
</div>
{/* Commit modal */}
<CommitModal
open={showCommitModal}
onClose={() => setShowCommitModal(false)}
batchId={batchId}
counts={counts}
validationResult={validationResult}
onCommitted={() => utils.dispo.getImportBatch.invalidate({ id: batchId })}
/>
{/* Cancel confirmation */}
{showCancelConfirm && (
<ConfirmDialog
title="Cancel Import"
message="Are you sure you want to cancel this import batch? All staged records will be discarded."
confirmLabel={cancelMutation.isPending ? "Cancelling..." : "Cancel Import"}
variant="danger"
onConfirm={() => cancelMutation.mutate({ id: batchId })}
onCancel={() => setShowCancelConfirm(false)}
/>
)}
</div>
);
}