"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 = { 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 ( {status} ); } 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 (

{label}

{count}

); } /* ------------------------------------------------------------------ */ /* 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 (
{/* Counts grid */}
0 ? "warning" : "default" } />
{/* Validation results */} {validationResult && (
{validationResult.blocking.length > 0 && (

Blocking Issues ({validationResult.blocking.length})

    {validationResult.blocking.map((msg, i) => (
  • {msg}
  • ))}
)} {validationResult.warnings.length > 0 && (

Warnings ({validationResult.warnings.length})

    {validationResult.warnings.map((msg, i) => (
  • {msg}
  • ))}
)} {validationResult.blocking.length === 0 && validationResult.warnings.length === 0 && (

All validation checks passed. Ready to commit.

)}
)} {/* Status indicator for failed batches */} {batch.status === "FAILED" && (

Error

This import batch has failed. Check the unresolved records tab for details.

)}
); } /* ------------------------------------------------------------------ */ /* 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[]; nextCursor?: string } | undefined; if (isLoading) return ; const records = data?.items ?? []; const total = records.length; return (
{records.map((r: Record) => ( ))} {records.length === 0 && ( )}
Name EID Chapter Status Warnings
{(r.displayName as string) ?? (r.name as string) ?? "-"} {(r.eid as string) ?? "-"} {(r.chapter as string) ?? "-"} {Array.isArray(r.warnings) && r.warnings.length > 0 ? ( {r.warnings.length} warning{r.warnings.length !== 1 ? "s" : ""} ) : ( - )}
No staged resources.
); } /* ------------------------------------------------------------------ */ /* 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[]; nextCursor?: string } | undefined; if (isLoading) return ; const records = data?.items ?? []; const total = records.length; return ( {records.map((r) => ( ))} {records.length === 0 && ( )}
Project Key Name Client TBD Status Warnings
{(r.projectKey as string) ?? "-"} {(r.name as string) ?? "-"} {(r.clientName as string) ?? "-"} {r.isTbd ? ( TBD ) : null} {Array.isArray(r.warnings) && r.warnings.length > 0 ? ( {r.warnings.length} ) : ( - )}
No staged projects.
); } /* ------------------------------------------------------------------ */ /* 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[]; nextCursor?: string } | undefined; if (isLoading) return ; const records = data?.items ?? []; const total = records.length; return ( {records.map((r: Record, idx: number) => ( ))} {records.length === 0 && ( )}
Resource Project Date Hours Role Status
{(r.resourceName as string) ?? (r.resourceEid as string) ?? "-"} {(r.projectName as string) ?? (r.projectKey as string) ?? "-"} {(r.date as string) ?? "-"} {r.hours != null ? String(r.hours) : "-"} {(r.roleName as string) ?? "-"}
No staged assignments.
); } /* ------------------------------------------------------------------ */ /* 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[]; nextCursor?: string } | undefined; if (isLoading) return ; const records = data?.items ?? []; const total = records.length; return ( {records.map((r: Record, idx: number) => ( ))} {records.length === 0 && ( )}
Resource Start End Type Status
{(r.resourceName as string) ?? (r.resourceEid as string) ?? "-"} {(r.startDate as string) ?? "-"} {(r.endDate as string) ?? "-"} {(r.vacationType as string) ?? "-"}
No staged vacations.
); } /* ------------------------------------------------------------------ */ /* 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[]; 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) => 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) => (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 ; return (
{/* Header with bulk actions */}

Unresolved Records

{remaining > 0 && ( {remaining} )}
{/* Table */}
{records.map((r: Record) => { const resolved = r.status === "APPROVED" || r.status === "REJECTED"; return ( ); })} {records.length === 0 && ( )}
Type Message Source Hint Actions
{(r.recordType as string) ?? "unknown"} {(r.message as string) ?? "-"} {(r.source as string) ?? "-"} {(r.hint as string) ?? "-"} {resolved ? ( {r.status as string} ) : (
)}
No unresolved records. Ready to commit.
); } /* ------------------------------------------------------------------ */ /* 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 (

Commit Import

{/* Validation results */} {validationResult && validationResult.blocking.length > 0 && (

Blocking Issues

    {validationResult.blocking.map((msg, i) => (
  • {msg}
  • ))}
)} {validationResult && validationResult.warnings.length > 0 && (

Warnings

    {validationResult.warnings.map((msg, i) => (
  • {msg}
  • ))}
)} {/* Entity counts */} {counts && (

Will be committed:

Resources: {counts.resourceCount}
Projects: {counts.projectCount}
Assignments: {counts.assignmentCount}
Vacations: {counts.vacationCount}
)} {/* Progress during commit */} {commitMutation.isPending && (
Committing...
)} {commitMutation.error && (

{commitMutation.error.message}

)}
); } /* ------------------------------------------------------------------ */ /* Shared Table Primitives */ /* ------------------------------------------------------------------ */ function Th({ children }: { children: React.ReactNode }) { return ( {children} ); } function Td({ children, mono }: { children: React.ReactNode; mono?: boolean }) { return ( {children} ); } function TableSkeleton({ rows, cols }: { rows: number; cols: number }) { return (
{Array.from({ length: rows }).map((_, i) => (
{Array.from({ length: cols }).map((_, j) => ( ))}
))}
); } 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 (
{children}
{totalPages > 1 && (

Showing {page * pageSize + 1}-{Math.min((page + 1) * pageSize, total)} of {total}

{page + 1} / {totalPages}
)}
); } /* ------------------------------------------------------------------ */ /* Main Component */ /* ------------------------------------------------------------------ */ export function DispoImportDetailClient({ batchId }: { batchId: string }) { const [activeTab, setActiveTab] = useState("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 (
{Array.from({ length: 5 }).map((_, i) => ( ))}
); } if (!batch) { return (

Batch not found.

Back to list
); } return (
{/* Back link */} All Imports {/* Header */}

Import Batch

{STATUS_BADGE[status]?.label ?? status}

{batchId}

Created: {formatDate(batch.createdAt as string)} Updated: {formatDate(batch.updatedAt as string)} {batch.committedAt && ( Committed: {formatDate(batch.committedAt as string)} )}
{/* Action buttons */}
{canValidate && ( )} {canReview && ( )} {canCancel && ( )}
{/* Tabs */}
{/* Tab content */}
{activeTab === "summary" && ( )} {activeTab === "resources" && } {activeTab === "projects" && } {activeTab === "assignments" && } {activeTab === "vacations" && } {activeTab === "unresolved" && }
{/* Commit modal */} setShowCommitModal(false)} batchId={batchId} counts={counts} validationResult={validationResult} onCommitted={() => utils.dispo.getImportBatch.invalidate({ id: batchId })} /> {/* Cancel confirmation */} {showCancelConfirm && ( cancelMutation.mutate({ id: batchId })} onCancel={() => setShowCancelConfirm(false)} /> )}
); }