diff --git a/apps/web/src/app/(app)/admin/dispo-imports/[batchId]/page.tsx b/apps/web/src/app/(app)/admin/dispo-imports/[batchId]/page.tsx new file mode 100644 index 0000000..e6432b7 --- /dev/null +++ b/apps/web/src/app/(app)/admin/dispo-imports/[batchId]/page.tsx @@ -0,0 +1,10 @@ +import { DispoImportDetailClient } from "~/components/admin/DispoImportDetailClient.js"; + +export default async function DispoImportDetailPage({ + params, +}: { + params: Promise<{ batchId: string }>; +}) { + const { batchId } = await params; + return ; +} diff --git a/apps/web/src/app/(app)/admin/dispo-imports/page.tsx b/apps/web/src/app/(app)/admin/dispo-imports/page.tsx new file mode 100644 index 0000000..81809b3 --- /dev/null +++ b/apps/web/src/app/(app)/admin/dispo-imports/page.tsx @@ -0,0 +1,5 @@ +import { DispoImportClient } from "~/components/admin/DispoImportClient.js"; + +export default function DispoImportsPage() { + return ; +} diff --git a/apps/web/src/components/admin/DispoImportClient.tsx b/apps/web/src/components/admin/DispoImportClient.tsx new file mode 100644 index 0000000..997616b --- /dev/null +++ b/apps/web/src/components/admin/DispoImportClient.tsx @@ -0,0 +1,317 @@ +"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 { ShimmerSkeleton } from "~/components/ui/ShimmerSkeleton.js"; + +/* ------------------------------------------------------------------ */ +/* Types (mirrors API output) */ +/* ------------------------------------------------------------------ */ + +type BatchStatus = + | "DRAFT" + | "STAGING" + | "STAGED" + | "REVIEW_READY" + | "APPROVED" + | "COMMITTING" + | "COMMITTED" + | "FAILED" + | "CANCELLED"; + +/* ------------------------------------------------------------------ */ +/* Status badge */ +/* ------------------------------------------------------------------ */ + +const STATUS_BADGE: Record = { + DRAFT: { label: "Draft", variant: "default" }, + STAGING: { label: "Staging", variant: "info" }, + APPROVED: { label: "Approved", variant: "success" }, + STAGED: { label: "Staged", variant: "info" }, + REVIEW_READY: { label: "Review Ready", variant: "warning" }, + COMMITTING: { label: "Committing", variant: "info" }, + COMMITTED: { label: "Committed", variant: "success" }, + FAILED: { label: "Failed", variant: "danger" }, + CANCELLED: { label: "Cancelled", variant: "default" }, +}; + +function StatusBadge({ status }: { status: BatchStatus }) { + const cfg = STATUS_BADGE[status] ?? STATUS_BADGE.DRAFT; + return {cfg.label}; +} + +/* ------------------------------------------------------------------ */ +/* Truncate ID helper */ +/* ------------------------------------------------------------------ */ + +function truncateId(id: string) { + return id.length > 12 ? `${id.slice(0, 8)}...` : id; +} + +/* ------------------------------------------------------------------ */ +/* New Import Modal */ +/* ------------------------------------------------------------------ */ + +const WORKBOOK_LABELS: { key: string; label: string; placeholder: string }[] = [ + { key: "resources", label: "Resources Workbook", placeholder: "/data/dispo/resources.xlsx" }, + { key: "projects", label: "Projects Workbook", placeholder: "/data/dispo/projects.xlsx" }, + { key: "assignments", label: "Assignments Workbook", placeholder: "/data/dispo/assignments.xlsx" }, + { key: "vacations", label: "Vacations Workbook", placeholder: "/data/dispo/vacations.xlsx" }, + { key: "roles", label: "Roles Workbook", placeholder: "/data/dispo/roles.xlsx" }, +]; + +function NewImportModal({ + open, + onClose, + onCreated, +}: { + open: boolean; + onClose: () => void; + onCreated: () => void; +}) { + const [filePaths, setFilePaths] = useState>({}); + const [error, setError] = useState(null); + + const stageMutation = trpc.dispo.stageImportBatch.useMutation({ + onSuccess: () => { + onCreated(); + onClose(); + setFilePaths({}); + setError(null); + }, + onError: (err) => setError(err.message), + }); + + function handleSubmit() { + setError(null); + const nonEmpty = Object.fromEntries( + Object.entries(filePaths).filter(([, v]) => v.trim().length > 0), + ); + if (Object.keys(nonEmpty).length === 0) { + setError("Provide at least one workbook path."); + return; + } + stageMutation.mutate({ + referenceWorkbookPath: (nonEmpty as Record).referenceWorkbookPath ?? "", + planningWorkbookPath: (nonEmpty as Record).planningWorkbookPath ?? "", + chargeabilityWorkbookPath: (nonEmpty as Record).chargeabilityWorkbookPath ?? "", + ...(nonEmpty.rosterWorkbookPath ? { rosterWorkbookPath: nonEmpty.rosterWorkbookPath } : {}), + ...(nonEmpty.costWorkbookPath ? { costWorkbookPath: nonEmpty.costWorkbookPath } : {}), + } as any); + } + + return ( + +
+

+ New Dispo Import +

+ +
+ {WORKBOOK_LABELS.map(({ key, label, placeholder }) => ( +
+ + + setFilePaths((prev) => ({ ...prev, [key]: e.target.value })) + } + /> +
+ ))} +
+ + {error && ( +

{error}

+ )} + +
+ + +
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Main Component */ +/* ------------------------------------------------------------------ */ + +export function DispoImportClient() { + const [statusFilter, setStatusFilter] = useState(""); + const [showNewModal, setShowNewModal] = useState(false); + + const utils = trpc.useUtils(); + + const { data: batches, isLoading } = trpc.dispo.listImportBatches.useQuery( + { status: statusFilter || undefined }, + { staleTime: 10_000 }, + ); + + return ( +
+ {/* Header */} +
+
+

+ Dispo Import +

+

+ Manage V2 data imports from Dispo workbooks +

+
+ +
+ + {/* Status filter */} +
+ + +
+ + {/* Table */} + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : !batches?.items || batches.items.length === 0 ? ( +
+

+ No import batches found. +

+
+ ) : ( +
+ + + + + + + + + + + + + {batches.items.map((batch: any) => ( + + + + + + + + + ))} + +
+ ID + + Status + + Source Files + + Staged + + Created + + Updated +
+ + {truncateId(batch.id)} + + + + + {batch.sourceFiles + ? Object.keys(batch.sourceFiles).join(", ") + : "-"} + + {batch.stagedCounts ? ( +
+ {batch.stagedCounts.resources} res + | + {batch.stagedCounts.projects} proj + | + {batch.stagedCounts.assignments} asgn + {batch.stagedCounts.unresolved > 0 && ( + <> + | + + {batch.stagedCounts.unresolved} unresolved + + + )} +
+ ) : ( + - + )} +
+ {new Date(batch.createdAt).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + {new Date(batch.updatedAt).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +
+
+ )} + + {/* New Import Modal */} + setShowNewModal(false)} + onCreated={() => utils.dispo.listImportBatches.invalidate()} + /> +
+ ); +} diff --git a/apps/web/src/components/admin/DispoImportDetailClient.tsx b/apps/web/src/components/admin/DispoImportDetailClient.tsx new file mode 100644 index 0000000..6c16b33 --- /dev/null +++ b/apps/web/src/components/admin/DispoImportDetailClient.tsx @@ -0,0 +1,1119 @@ +"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 && ( + + + + )} + +
NameEIDChapterStatusWarnings
{(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 KeyNameClientTBDStatusWarnings
{(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 && ( + + + + )} + +
ResourceProjectDateHoursRoleStatus
{(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 && ( + + + + )} + +
ResourceStartEndTypeStatus
{(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 && ( + + + + )} + +
TypeMessageSourceHintActions
+ + {(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)} + /> + )} +
+ ); +} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 3a76601..5cef27a 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -189,6 +189,7 @@ const adminNavEntries: AdminEntry[] = [ { href: "/admin/skill-import", label: "Skill Import", icon: }, { href: "/admin/notifications", label: "Broadcasts", icon: }, { href: "/admin/webhooks", label: "Webhooks", icon: }, + { href: "/admin/dispo-imports", label: "Dispo Import", icon: }, ]; /** diff --git a/packages/api/src/router/dispo.ts b/packages/api/src/router/dispo.ts new file mode 100644 index 0000000..4feaea4 --- /dev/null +++ b/packages/api/src/router/dispo.ts @@ -0,0 +1,423 @@ +import { + ImportBatchStatus, + StagedRecordStatus, + DispoStagedRecordType, +} from "@planarchy/db"; +import { + assessDispoImportReadiness, + commitDispoImportBatch, + stageDispoImportBatch, +} from "@planarchy/application"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { adminProcedure, createTRPCRouter } from "../trpc.js"; + +// ─── Shared schemas ────────────────────────────────────────────────────────── + +const paginationSchema = z.object({ + cursor: z.string().optional(), + limit: z.number().int().min(1).max(200).default(50), +}); + +const importBatchStatusSchema = z.nativeEnum(ImportBatchStatus); +const stagedRecordStatusSchema = z.nativeEnum(StagedRecordStatus); +const stagedRecordTypeSchema = z.nativeEnum(DispoStagedRecordType); + +// ─── Router ────────────────────────────────────────────────────────────────── + +export const dispoRouter = createTRPCRouter({ + // ── 1. stageImportBatch ────────────────────────────────────────────────── + + stageImportBatch: adminProcedure + .input( + z.object({ + chargeabilityWorkbookPath: z.string(), + costWorkbookPath: z.string().optional(), + notes: z.string().nullish(), + planningWorkbookPath: z.string(), + referenceWorkbookPath: z.string(), + rosterWorkbookPath: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + return stageDispoImportBatch(ctx.db, { + chargeabilityWorkbookPath: input.chargeabilityWorkbookPath, + planningWorkbookPath: input.planningWorkbookPath, + referenceWorkbookPath: input.referenceWorkbookPath, + ...(input.costWorkbookPath !== undefined ? { costWorkbookPath: input.costWorkbookPath } : {}), + ...(input.notes !== undefined ? { notes: input.notes } : {}), + ...(input.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: input.rosterWorkbookPath } : {}), + }); + }), + + // ── 2. validateImportBatch ─────────────────────────────────────────────── + + validateImportBatch: adminProcedure + .input( + z.object({ + chargeabilityWorkbookPath: z.string(), + costWorkbookPath: z.string().optional(), + importBatchId: z.string().optional(), + notes: z.string().nullish(), + planningWorkbookPath: z.string(), + referenceWorkbookPath: z.string(), + rosterWorkbookPath: z.string().optional(), + }), + ) + .query(async ({ input }) => { + return assessDispoImportReadiness({ + chargeabilityWorkbookPath: input.chargeabilityWorkbookPath, + planningWorkbookPath: input.planningWorkbookPath, + referenceWorkbookPath: input.referenceWorkbookPath, + ...(input.costWorkbookPath !== undefined ? { costWorkbookPath: input.costWorkbookPath } : {}), + ...(input.importBatchId !== undefined ? { importBatchId: input.importBatchId } : {}), + ...(input.notes !== undefined ? { notes: input.notes } : {}), + ...(input.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: input.rosterWorkbookPath } : {}), + }); + }), + + // ── 3. listImportBatches ───────────────────────────────────────────────── + + listImportBatches: adminProcedure + .input( + paginationSchema.extend({ + status: importBatchStatusSchema.optional(), + }), + ) + .query(async ({ ctx, input }) => { + const { cursor, limit, status } = input; + + const items = await ctx.db.importBatch.findMany({ + where: { + ...(status !== undefined ? { status } : {}), + }, + orderBy: { createdAt: "desc" }, + take: limit + 1, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + + let nextCursor: string | undefined; + if (items.length > limit) { + const next = items.pop(); + nextCursor = next?.id; + } + + return { items, nextCursor }; + }), + + // ── 4. getImportBatch ──────────────────────────────────────────────────── + + getImportBatch: adminProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const batch = await ctx.db.importBatch.findUnique({ + where: { id: input.id }, + }); + + if (!batch) { + throw new TRPCError({ code: "NOT_FOUND", message: `Import batch "${input.id}" not found` }); + } + + const [ + resourceCount, + clientCount, + projectCount, + assignmentCount, + vacationCount, + availabilityRuleCount, + unresolvedCount, + ] = await Promise.all([ + ctx.db.stagedResource.count({ where: { importBatchId: input.id } }), + ctx.db.stagedClient.count({ where: { importBatchId: input.id } }), + ctx.db.stagedProject.count({ where: { importBatchId: input.id } }), + ctx.db.stagedAssignment.count({ where: { importBatchId: input.id } }), + ctx.db.stagedVacation.count({ where: { importBatchId: input.id } }), + ctx.db.stagedAvailabilityRule.count({ where: { importBatchId: input.id } }), + ctx.db.stagedUnresolvedRecord.count({ where: { importBatchId: input.id } }), + ]); + + return { + ...batch, + counts: { + assignmentCount, + availabilityRuleCount, + clientCount, + projectCount, + resourceCount, + unresolvedCount, + vacationCount, + }, + }; + }), + + // ── 5. cancelImportBatch ───────────────────────────────────────────────── + + cancelImportBatch: adminProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const batch = await ctx.db.importBatch.findUnique({ + where: { id: input.id }, + select: { id: true, status: true }, + }); + + if (!batch) { + throw new TRPCError({ code: "NOT_FOUND", message: `Import batch "${input.id}" not found` }); + } + + const terminalStatuses: ImportBatchStatus[] = [ + ImportBatchStatus.COMMITTED, + ImportBatchStatus.CANCELLED, + ]; + if (terminalStatuses.includes(batch.status)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Cannot cancel batch in status "${batch.status}"`, + }); + } + + return ctx.db.importBatch.update({ + where: { id: input.id }, + data: { status: ImportBatchStatus.CANCELLED }, + }); + }), + + // ── 6. listStagedResources ─────────────────────────────────────────────── + + listStagedResources: adminProcedure + .input( + paginationSchema.extend({ + importBatchId: z.string(), + status: stagedRecordStatusSchema.optional(), + }), + ) + .query(async ({ ctx, input }) => { + const { cursor, importBatchId, limit, status } = input; + + const items = await ctx.db.stagedResource.findMany({ + where: { + importBatchId, + ...(status !== undefined ? { status } : {}), + }, + orderBy: { canonicalExternalId: "asc" }, + take: limit + 1, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + + let nextCursor: string | undefined; + if (items.length > limit) { + const next = items.pop(); + nextCursor = next?.id; + } + + return { items, nextCursor }; + }), + + // ── 7. listStagedProjects ──────────────────────────────────────────────── + + listStagedProjects: adminProcedure + .input( + paginationSchema.extend({ + importBatchId: z.string(), + isTbd: z.boolean().optional(), + status: stagedRecordStatusSchema.optional(), + }), + ) + .query(async ({ ctx, input }) => { + const { cursor, importBatchId, isTbd, limit, status } = input; + + const items = await ctx.db.stagedProject.findMany({ + where: { + importBatchId, + ...(status !== undefined ? { status } : {}), + ...(isTbd !== undefined ? { isTbd } : {}), + }, + orderBy: { projectKey: "asc" }, + take: limit + 1, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + + let nextCursor: string | undefined; + if (items.length > limit) { + const next = items.pop(); + nextCursor = next?.id; + } + + return { items, nextCursor }; + }), + + // ── 8. listStagedAssignments ───────────────────────────────────────────── + + listStagedAssignments: adminProcedure + .input( + paginationSchema.extend({ + importBatchId: z.string(), + resourceExternalId: z.string().optional(), + status: stagedRecordStatusSchema.optional(), + }), + ) + .query(async ({ ctx, input }) => { + const { cursor, importBatchId, limit, resourceExternalId, status } = input; + + const items = await ctx.db.stagedAssignment.findMany({ + where: { + importBatchId, + ...(status !== undefined ? { status } : {}), + ...(resourceExternalId !== undefined ? { resourceExternalId } : {}), + }, + orderBy: { createdAt: "asc" }, + take: limit + 1, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + + let nextCursor: string | undefined; + if (items.length > limit) { + const next = items.pop(); + nextCursor = next?.id; + } + + return { items, nextCursor }; + }), + + // ── 9. listStagedVacations ─────────────────────────────────────────────── + + listStagedVacations: adminProcedure + .input( + paginationSchema.extend({ + importBatchId: z.string(), + resourceExternalId: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const { cursor, importBatchId, limit, resourceExternalId } = input; + + const items = await ctx.db.stagedVacation.findMany({ + where: { + importBatchId, + ...(resourceExternalId !== undefined ? { resourceExternalId } : {}), + }, + orderBy: { startDate: "asc" }, + take: limit + 1, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + + let nextCursor: string | undefined; + if (items.length > limit) { + const next = items.pop(); + nextCursor = next?.id; + } + + return { items, nextCursor }; + }), + + // ── 10. listStagedUnresolvedRecords ────────────────────────────────────── + + listStagedUnresolvedRecords: adminProcedure + .input( + paginationSchema.extend({ + importBatchId: z.string(), + recordType: stagedRecordTypeSchema.optional(), + }), + ) + .query(async ({ ctx, input }) => { + const { cursor, importBatchId, limit, recordType } = input; + + const items = await ctx.db.stagedUnresolvedRecord.findMany({ + where: { + importBatchId, + ...(recordType !== undefined ? { recordType } : {}), + }, + orderBy: { createdAt: "asc" }, + take: limit + 1, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + + let nextCursor: string | undefined; + if (items.length > limit) { + const next = items.pop(); + nextCursor = next?.id; + } + + return { items, nextCursor }; + }), + + // ── 11. resolveStagedRecord ────────────────────────────────────────────── + + resolveStagedRecord: adminProcedure + .input( + z.object({ + action: z.enum(["APPROVE", "REJECT", "SKIP"]), + id: z.string(), + recordType: stagedRecordTypeSchema, + }), + ) + .mutation(async ({ ctx, input }) => { + const statusMap: Record = { + APPROVE: StagedRecordStatus.APPROVED, + REJECT: StagedRecordStatus.REJECTED, + SKIP: StagedRecordStatus.REJECTED, + }; + const nextStatus = statusMap[input.action]!; + + // Delegate table lookup based on record type + switch (input.recordType) { + case DispoStagedRecordType.RESOURCE: + return ctx.db.stagedResource.update({ + where: { id: input.id }, + data: { status: nextStatus }, + }); + case DispoStagedRecordType.CLIENT: + return ctx.db.stagedClient.update({ + where: { id: input.id }, + data: { status: nextStatus }, + }); + case DispoStagedRecordType.PROJECT: + return ctx.db.stagedProject.update({ + where: { id: input.id }, + data: { status: nextStatus }, + }); + case DispoStagedRecordType.ASSIGNMENT: + return ctx.db.stagedAssignment.update({ + where: { id: input.id }, + data: { status: nextStatus }, + }); + case DispoStagedRecordType.VACATION: + return ctx.db.stagedVacation.update({ + where: { id: input.id }, + data: { status: nextStatus }, + }); + case DispoStagedRecordType.AVAILABILITY_RULE: + return ctx.db.stagedAvailabilityRule.update({ + where: { id: input.id }, + data: { status: nextStatus }, + }); + case DispoStagedRecordType.UNRESOLVED: + return ctx.db.stagedUnresolvedRecord.update({ + where: { id: input.id }, + data: { status: nextStatus }, + }); + default: + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unknown record type: ${input.recordType as string}`, + }); + } + }), + + // ── 12. commitImportBatch ──────────────────────────────────────────────── + + commitImportBatch: adminProcedure + .input( + z.object({ + allowTbdUnresolved: z.boolean().optional(), + importBatchId: z.string(), + importTbdProjects: z.boolean().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + return commitDispoImportBatch(ctx.db, { + importBatchId: input.importBatchId, + ...(input.allowTbdUnresolved !== undefined ? { allowTbdUnresolved: input.allowTbdUnresolved } : {}), + ...(input.importTbdProjects !== undefined ? { importTbdProjects: input.importTbdProjects } : {}), + }); + }), +}); diff --git a/packages/api/src/router/index.ts b/packages/api/src/router/index.ts index 38f4df3..00e3efd 100644 --- a/packages/api/src/router/index.ts +++ b/packages/api/src/router/index.ts @@ -9,6 +9,7 @@ import { clientRouter } from "./client.js"; import { commentRouter } from "./comment.js"; import { countryRouter } from "./country.js"; import { dashboardRouter } from "./dashboard.js"; +import { dispoRouter } from "./dispo.js"; import { effortRuleRouter } from "./effort-rule.js"; import { experienceMultiplierRouter } from "./experience-multiplier.js"; import { estimateRouter } from "./estimate.js"; @@ -36,6 +37,7 @@ import { webhookRouter } from "./webhook.js"; export const appRouter = createTRPCRouter({ assistant: assistantRouter, dashboard: dashboardRouter, + dispo: dispoRouter, effortRule: effortRuleRouter, experienceMultiplier: experienceMultiplierRouter, estimate: estimateRouter,