7e4b21afe9
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>
1120 lines
40 KiB
TypeScript
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>
|
|
);
|
|
}
|