From 7b48b26c26729c05267716079934b13938c8d059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 22 Mar 2026 18:53:11 +0100 Subject: [PATCH 01/47] fix: timeline filter dropdown clipped by overflow-hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove overflow-hidden from timeline page wrapper — it was clipping the ProjectCombobox and ResourceCombobox dropdown menus. The max-h-[100dvh] constraint combined with min-h-0 on flex children is sufficient to keep the scroll container within viewport bounds. Co-Authored-By: claude-flow --- apps/web/src/app/(app)/timeline/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(app)/timeline/page.tsx b/apps/web/src/app/(app)/timeline/page.tsx index 6f8e683..d16dc8c 100644 --- a/apps/web/src/app/(app)/timeline/page.tsx +++ b/apps/web/src/app/(app)/timeline/page.tsx @@ -15,7 +15,7 @@ const TimelineView = dynamic( export default function TimelinePage() { return ( -
+

Timeline

From 7a57b5e6491709e45528c38dd8e14c371f25a95b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 22 Mar 2026 18:55:18 +0100 Subject: [PATCH 02/47] fix: timeline filter dropdown rendered behind scroll container Add relative z-20 to TimelineToolbar so ProjectCombobox and ResourceCombobox dropdowns (z-[60]) render above the scroll container below. The scroll container's relative positioning was creating a stacking context that overlapped the dropdowns. Co-Authored-By: claude-flow --- apps/web/src/components/timeline/TimelineToolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/timeline/TimelineToolbar.tsx b/apps/web/src/components/timeline/TimelineToolbar.tsx index 3bbea95..d74d1c8 100644 --- a/apps/web/src/components/timeline/TimelineToolbar.tsx +++ b/apps/web/src/components/timeline/TimelineToolbar.tsx @@ -90,7 +90,7 @@ export function TimelineToolbar({ } return ( -
+
Date: Sun, 22 Mar 2026 19:07:20 +0100 Subject: [PATCH 03/47] =?UTF-8?q?feat:=20Dispo=20V2=20import=20=E2=80=94?= =?UTF-8?q?=20API=20router=20+=20admin=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../admin/dispo-imports/[batchId]/page.tsx | 10 + .../app/(app)/admin/dispo-imports/page.tsx | 5 + .../components/admin/DispoImportClient.tsx | 317 +++++ .../admin/DispoImportDetailClient.tsx | 1119 +++++++++++++++++ apps/web/src/components/layout/AppShell.tsx | 1 + packages/api/src/router/dispo.ts | 423 +++++++ packages/api/src/router/index.ts | 2 + 7 files changed, 1877 insertions(+) create mode 100644 apps/web/src/app/(app)/admin/dispo-imports/[batchId]/page.tsx create mode 100644 apps/web/src/app/(app)/admin/dispo-imports/page.tsx create mode 100644 apps/web/src/components/admin/DispoImportClient.tsx create mode 100644 apps/web/src/components/admin/DispoImportDetailClient.tsx create mode 100644 packages/api/src/router/dispo.ts 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, From da984da470598efcc19300127c885f98ecd73547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 22 Mar 2026 19:10:24 +0100 Subject: [PATCH 04/47] =?UTF-8?q?fix:=20timeline=20filter=20dropdown=20z-i?= =?UTF-8?q?ndex=20=E2=80=94=20add=20z-0=20to=20scroll=20container?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scroll container's relative positioning created a stacking context that rendered its children (z-30 sticky labels) above the toolbar's dropdown (z-[60]). Adding z-0 to the scroll container explicitly places it below the toolbar's z-20 stacking context, allowing dropdowns to render on top. Co-Authored-By: claude-flow --- apps/web/src/components/timeline/TimelineView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index 88881f3..186824c 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -615,7 +615,7 @@ function TimelineViewContent({
{isInitialLoading ? (
From 5d9f4218a00288eb6271efb3ac86424fb0a5c6a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 22 Mar 2026 19:25:08 +0100 Subject: [PATCH 05/47] =?UTF-8?q?refactor:=20Blueprint=20UI=20=E2=80=94=20?= =?UTF-8?q?catalog-based=20field=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual field definition with a visual field catalog where admins toggle available attributes on/off and set defaults inline. New files: - blueprint-field-catalog.ts: 36 pre-defined fields across 7 categories (Client & Billing, Technical Specs, Scope, Person Info, Organization, Contract, Skills & Work) for both PROJECT and RESOURCE targets - FieldCard.tsx: toggle card with type icon, expandable default value editor, required/show-in-list toggles, helper text - BlueprintFieldCatalog.tsx: main catalog modal with category sidebar, search bar, collapsible sections, custom field support UX improvements: - All standard fields pre-defined — users toggle instead of typing - Default values set inline on each card (type-appropriate inputs) - Fields grouped by category with enable counts - Search/filter to find fields quickly - Custom fields still supported via "Add Custom Field" - Full backward compatibility: existing fieldDefs auto-map to catalog Co-Authored-By: claude-flow --- .../blueprints/BlueprintFieldCatalog.tsx | 786 ++++++++++++++++++ .../blueprints/BlueprintsClient.tsx | 5 +- .../src/components/blueprints/FieldCard.tsx | 383 +++++++++ apps/web/src/lib/blueprint-field-catalog.ts | 517 ++++++++++++ 4 files changed, 1689 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/blueprints/BlueprintFieldCatalog.tsx create mode 100644 apps/web/src/components/blueprints/FieldCard.tsx create mode 100644 apps/web/src/lib/blueprint-field-catalog.ts diff --git a/apps/web/src/components/blueprints/BlueprintFieldCatalog.tsx b/apps/web/src/components/blueprints/BlueprintFieldCatalog.tsx new file mode 100644 index 0000000..2091132 --- /dev/null +++ b/apps/web/src/components/blueprints/BlueprintFieldCatalog.tsx @@ -0,0 +1,786 @@ +"use client"; + +import { useState, useMemo, useCallback } from "react"; +import { FieldType } from "@planarchy/shared"; +import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@planarchy/shared"; +import { trpc } from "~/lib/trpc/client.js"; +import { RolePresetsEditor } from "./RolePresetsEditor.js"; +import { FieldCard } from "./FieldCard.js"; +import type { FieldOverrides } from "./FieldCard.js"; +import { + getCatalogForTarget, + getCategoriesForTarget, + findCatalogField, +} from "~/lib/blueprint-field-catalog.js"; +import type { CatalogField } from "~/lib/blueprint-field-catalog.js"; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const INPUT_CLS = + "px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm"; + +const BTN_PRIMARY = + "px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"; + +const BTN_SECONDARY = + "px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type BlueprintTargetValue = "RESOURCE" | "PROJECT"; + +/** Internal state for a field: catalog index or custom definition */ +interface FieldState { + /** Catalog key (undefined for custom fields) */ + catalogKey: string | undefined; + overrides: FieldOverrides; + /** For custom fields only */ + custom?: { + key: string; + label: string; + type: FieldType; + options: FieldOption[]; + }; +} + +// --------------------------------------------------------------------------- +// Helpers: Convert between FieldState and BlueprintFieldDefinition +// --------------------------------------------------------------------------- + +function fieldDefToState( + def: BlueprintFieldDefinition, + target: BlueprintTargetValue, +): FieldState { + const catalogField = findCatalogField(target, def.key); + if (catalogField) { + return { + catalogKey: catalogField.key, + overrides: { + enabled: true, + required: def.required, + showInList: def.showInList ?? false, + defaultValue: def.defaultValue, + description: def.description ?? "", + }, + }; + } + // Custom field -- not in catalog + return { + catalogKey: undefined, + overrides: { + enabled: true, + required: def.required, + showInList: def.showInList ?? false, + defaultValue: def.defaultValue, + description: def.description ?? "", + }, + custom: { + key: def.key, + label: def.label, + type: def.type, + options: def.options ?? [], + }, + }; +} + +function stateToFieldDef( + state: FieldState, + order: number, + target: BlueprintTargetValue, +): BlueprintFieldDefinition | null { + if (!state.overrides.enabled) return null; + + if (state.catalogKey) { + const catalogField = findCatalogField(target, state.catalogKey); + if (!catalogField) return null; + const desc = state.overrides.description || catalogField.description; + return { + id: catalogField.key, + key: catalogField.key, + label: catalogField.label, + type: catalogField.type, + required: state.overrides.required, + order, + ...(state.overrides.showInList ? { showInList: true } : {}), + ...(desc ? { description: desc } : {}), + defaultValue: state.overrides.defaultValue, + ...(catalogField.options ? { options: catalogField.options } : {}), + }; + } + + // Custom field + if (!state.custom) return null; + const customDesc = state.overrides.description || undefined; + return { + id: state.custom.key, + key: state.custom.key, + label: state.custom.label, + type: state.custom.type, + required: state.overrides.required, + order, + ...(state.overrides.showInList ? { showInList: true } : {}), + ...(customDesc !== undefined ? { description: customDesc } : {}), + defaultValue: state.overrides.defaultValue, + ...(state.custom.options.length > 0 ? { options: state.custom.options } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface BlueprintFieldCatalogProps { + blueprintId: string; + blueprintName: string; + blueprintTarget: BlueprintTargetValue; + initialFieldDefs: BlueprintFieldDefinition[]; + initialRolePresets?: StaffingRequirement[]; + initialTab?: "fields" | "presets"; + onClose: () => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +const FIELD_TYPES: { value: FieldType; label: string }[] = [ + { value: FieldType.TEXT, label: "Text" }, + { value: FieldType.TEXTAREA, label: "Textarea" }, + { value: FieldType.NUMBER, label: "Number" }, + { value: FieldType.BOOLEAN, label: "Boolean" }, + { value: FieldType.DATE, label: "Date" }, + { value: FieldType.SELECT, label: "Select" }, + { value: FieldType.MULTI_SELECT, label: "Multi-Select" }, + { value: FieldType.URL, label: "URL" }, + { value: FieldType.EMAIL, label: "Email" }, +]; + +export function BlueprintFieldCatalog({ + blueprintId, + blueprintName, + blueprintTarget, + initialFieldDefs, + initialRolePresets = [], + initialTab = "fields", + onClose, +}: BlueprintFieldCatalogProps) { + const utils = trpc.useUtils(); + + const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab); + const [searchQuery, setSearchQuery] = useState(""); + const [activeCategory, setActiveCategory] = useState(null); + const [saveError, setSaveError] = useState(null); + const [presetSaveError, setPresetSaveError] = useState(null); + + // -- Custom field form state -- + const [showCustomForm, setShowCustomForm] = useState(false); + const [customKey, setCustomKey] = useState(""); + const [customLabel, setCustomLabel] = useState(""); + const [customType, setCustomType] = useState(FieldType.TEXT); + + const catalog = useMemo(() => getCatalogForTarget(blueprintTarget), [blueprintTarget]); + const categories = useMemo(() => getCategoriesForTarget(blueprintTarget), [blueprintTarget]); + + // --------------------------------------------------------------------------- + // Build initial state from existing fieldDefs + catalog + // --------------------------------------------------------------------------- + + const [catalogOverrides, setCatalogOverrides] = useState< + Record + >(() => { + const map: Record = {}; + // Start with all catalog fields disabled + for (const cf of catalog) { + map[cf.key] = { + enabled: false, + required: false, + showInList: false, + defaultValue: cf.defaultValue, + description: "", + }; + } + // Override from existing fieldDefs + for (const def of initialFieldDefs) { + const state = fieldDefToState(def, blueprintTarget); + if (state.catalogKey && map[state.catalogKey]) { + map[state.catalogKey] = state.overrides; + } + } + return map; + }); + + const [customFields, setCustomFields] = useState(() => { + return initialFieldDefs + .map((def) => fieldDefToState(def, blueprintTarget)) + .filter((s) => !s.catalogKey); + }); + + // --------------------------------------------------------------------------- + // Mutations + // --------------------------------------------------------------------------- + + const updateMutation = trpc.blueprint.update.useMutation(); + const presetMutation = trpc.blueprint.updateRolePresets.useMutation(); + + // --------------------------------------------------------------------------- + // Derived data + // --------------------------------------------------------------------------- + + const allCategoryNames = useMemo( + () => [...categories.map((c) => c.name), "Custom Fields"], + [categories], + ); + + const filteredCatalog = useMemo(() => { + if (!searchQuery.trim()) return catalog; + const q = searchQuery.toLowerCase(); + return catalog.filter( + (f) => + f.label.toLowerCase().includes(q) || + f.key.toLowerCase().includes(q) || + f.description.toLowerCase().includes(q) || + f.category.toLowerCase().includes(q), + ); + }, [catalog, searchQuery]); + + const fieldsByCategory = useMemo(() => { + const map = new Map(); + for (const cat of categories) { + map.set(cat.name, []); + } + for (const f of filteredCatalog) { + const list = map.get(f.category); + if (list) list.push(f); + } + return map; + }, [filteredCatalog, categories]); + + const enabledCount = useMemo(() => { + let count = 0; + for (const ov of Object.values(catalogOverrides)) { + if (ov.enabled) count++; + } + count += customFields.filter((f) => f.overrides.enabled).length; + return count; + }, [catalogOverrides, customFields]); + + // --------------------------------------------------------------------------- + // Handlers + // --------------------------------------------------------------------------- + + const handleCatalogFieldChange = useCallback( + (key: string, overrides: FieldOverrides) => { + setCatalogOverrides((prev) => ({ ...prev, [key]: overrides })); + }, + [], + ); + + const handleCustomFieldChange = useCallback( + (idx: number, overrides: FieldOverrides) => { + setCustomFields((prev) => + prev.map((f, i) => (i === idx ? { ...f, overrides } : f)), + ); + }, + [], + ); + + function removeCustomField(idx: number) { + setCustomFields((prev) => prev.filter((_, i) => i !== idx)); + } + + function addCustomField() { + if (!customKey.trim() || !customLabel.trim()) return; + // Check for duplicate key + const allKeys = new Set([ + ...catalog.map((f) => f.key), + ...customFields.map((f) => f.custom?.key).filter(Boolean), + ]); + if (allKeys.has(customKey.trim())) return; + + setCustomFields((prev) => [ + ...prev, + { + catalogKey: undefined, + overrides: { + enabled: true, + required: false, + showInList: false, + defaultValue: undefined, + description: "", + }, + custom: { + key: customKey.trim(), + label: customLabel.trim(), + type: customType, + options: [], + }, + }, + ]); + setCustomKey(""); + setCustomLabel(""); + setCustomType(FieldType.TEXT); + setShowCustomForm(false); + } + + function handleSave() { + setSaveError(null); + const defs: BlueprintFieldDefinition[] = []; + let order = 0; + + // Catalog fields first (in catalog order) + for (const cf of catalog) { + const ov = catalogOverrides[cf.key]; + if (!ov?.enabled) continue; + const state: FieldState = { catalogKey: cf.key, overrides: ov }; + const def = stateToFieldDef(state, order, blueprintTarget); + if (def) { + defs.push(def); + order++; + } + } + + // Custom fields + for (const cf of customFields) { + if (!cf.overrides.enabled) continue; + const def = stateToFieldDef(cf, order, blueprintTarget); + if (def) { + defs.push(def); + order++; + } + } + + updateMutation.mutate( + { id: blueprintId, data: { fieldDefs: defs } }, + { + onSuccess: async () => { + await utils.blueprint.list.invalidate(); + onClose(); + }, + onError: (err) => setSaveError(err.message), + }, + ); + } + + function handleBackdropClick(e: React.MouseEvent) { + if (e.target === e.currentTarget) onClose(); + } + + // --------------------------------------------------------------------------- + // Collapsed categories + // --------------------------------------------------------------------------- + + const [collapsedCategories, setCollapsedCategories] = useState>( + new Set(), + ); + + function toggleCategory(name: string) { + setCollapsedCategories((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + } + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + + return ( +
+
+ {/* Header */} +
+
+

+ Configure Fields:{" "} + {blueprintName} +

+

+ {enabledCount} field{enabledCount !== 1 ? "s" : ""} enabled +

+
+ +
+ + {/* Tabs */} +
+ {(["fields", "presets"] as const).map((tab) => ( + + ))} +
+ + {activeTab === "fields" ? ( + <> + {/* Search + category sidebar layout */} +
+ {/* Category sidebar */} +
+ +
+ + {/* Main content */} +
+ {/* Search bar */} +
+ setSearchQuery(e.target.value)} + placeholder="Search fields..." + className={`${INPUT_CLS} w-full`} + autoFocus + /> +
+ + {/* Field cards */} +
+ {categories + .filter( + (cat) => + activeCategory === null || + activeCategory === cat.name, + ) + .map((cat) => { + const fields = fieldsByCategory.get(cat.name) ?? []; + if (fields.length === 0 && searchQuery.trim()) return null; + if (fields.length === 0 && activeCategory !== null && activeCategory !== cat.name) return null; + + const isCollapsed = collapsedCategories.has(cat.name); + + return ( +
+ + {!isCollapsed && ( +
+ {fields.map((field) => ( + + handleCatalogFieldChange(field.key, ov) + } + /> + ))} + {fields.length === 0 && ( +

+ No fields in this category. +

+ )} +
+ )} +
+ ); + })} + + {/* Custom Fields section */} + {(activeCategory === null || + activeCategory === "Custom Fields") && ( +
+ + {!collapsedCategories.has("Custom Fields") && ( +
+ {customFields.map((cf, idx) => { + if (!cf.custom) return null; + // Build a pseudo CatalogField for the FieldCard + const pseudoCatalog: CatalogField = { + key: cf.custom.key, + label: cf.custom.label, + type: cf.custom.type, + category: "Custom Fields", + description: + cf.overrides.description || "Custom field", + ...(cf.custom.options.length > 0 + ? { options: cf.custom.options } + : {}), + builtIn: false, + }; + return ( +
+ + handleCustomFieldChange(idx, ov) + } + /> + +
+ ); + })} + + {/* Add custom field */} + {showCustomForm ? ( +
+
+
+ + + setCustomKey( + e.target.value.replace( + /[^a-zA-Z0-9_]/g, + "", + ), + ) + } + placeholder="field_key" + className={`${INPUT_CLS} font-mono`} + /> +
+
+ + + setCustomLabel(e.target.value) + } + placeholder="Display Label" + className={INPUT_CLS} + /> +
+
+ + +
+
+
+ + +
+
+ ) : ( + + )} +
+ )} +
+ )} +
+
+
+ + {/* Error */} + {saveError && ( +
+ {saveError} +
+ )} + + {/* Footer */} +
+ + {enabledCount} field{enabledCount !== 1 ? "s" : ""} will be + saved + +
+ + +
+
+ + ) : ( +
+

+ Role presets are auto-loaded in Step 3 of the Project Creation + Wizard when this blueprint is selected. +

+ + presetMutation.mutate( + { id: blueprintId, rolePresets: presets }, + { + onSuccess: async () => { + await utils.blueprint.list.invalidate(); + setPresetSaveError(null); + onClose(); + }, + onError: (err) => { + setPresetSaveError(err.message); + }, + }, + ) + } + isSaving={presetMutation.isPending} + saveError={presetSaveError} + /> +
+ +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/blueprints/BlueprintsClient.tsx b/apps/web/src/components/blueprints/BlueprintsClient.tsx index 5952034..f5f8c5d 100644 --- a/apps/web/src/components/blueprints/BlueprintsClient.tsx +++ b/apps/web/src/components/blueprints/BlueprintsClient.tsx @@ -5,7 +5,7 @@ import type { FormEvent, MouseEvent } from "react"; import { BlueprintTarget } from "@planarchy/shared"; import type { BlueprintFieldDefinition } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; -import { BlueprintFieldEditor } from "./BlueprintFieldEditor.js"; +import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js"; import { useSelection } from "~/hooks/useSelection.js"; import { BatchActionBar } from "~/components/ui/BatchActionBar.js"; import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; @@ -496,9 +496,10 @@ export function BlueprintsClient() { )} {editingBlueprint && ( - = { + [FieldType.TEXT]: "Aa", + [FieldType.TEXTAREA]: "Aa", + [FieldType.NUMBER]: "#", + [FieldType.BOOLEAN]: "\u2611", + [FieldType.DATE]: "\u{1F4C5}", + [FieldType.SELECT]: "\u25BC", + [FieldType.MULTI_SELECT]: "\u25BC\u25BC", + [FieldType.URL]: "\u{1F517}", + [FieldType.EMAIL]: "@", +}; + +const TYPE_LABELS: Record = { + [FieldType.TEXT]: "Text", + [FieldType.TEXTAREA]: "Textarea", + [FieldType.NUMBER]: "Number", + [FieldType.BOOLEAN]: "Boolean", + [FieldType.DATE]: "Date", + [FieldType.SELECT]: "Select", + [FieldType.MULTI_SELECT]: "Multi-Select", + [FieldType.URL]: "URL", + [FieldType.EMAIL]: "Email", +}; + +// --------------------------------------------------------------------------- +// Field overrides that the user can set per-field +// --------------------------------------------------------------------------- + +export interface FieldOverrides { + enabled: boolean; + required: boolean; + showInList: boolean; + defaultValue: unknown; + description: string; +} + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface FieldCardProps { + field: CatalogField; + overrides: FieldOverrides; + onChange: (overrides: FieldOverrides) => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +const INPUT_CLS = + "px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm"; + +export function FieldCard({ field, overrides, onChange }: FieldCardProps) { + const [expanded, setExpanded] = useState(false); + + function update(patch: Partial) { + onChange({ ...overrides, ...patch }); + } + + function handleToggle() { + const next = !overrides.enabled; + update({ enabled: next }); + if (!next) { + setExpanded(false); + } + } + + const isActive = overrides.enabled; + + return ( +
+ {/* Header row */} +
{ + if (isActive) setExpanded((v) => !v); + else handleToggle(); + }} + > + {/* Type icon */} + + {TYPE_ICONS[field.type]} + + + {/* Label + description */} +
+
+ + {field.label} + + + {field.key} + +
+

{field.description}

+
+ + {/* Toggle switch */} + +
+ + {/* Expanded settings */} + {isActive && expanded && ( +
+ {/* Default value input */} +
+ + update({ defaultValue: val })} + /> +
+ + {/* Toggles row */} +
+ + +
+ + {/* Description override */} +
+ + update({ description: e.target.value })} + placeholder={field.description} + className={INPUT_CLS} + /> +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Type-appropriate default value input +// --------------------------------------------------------------------------- + +function DefaultValueInput({ + type, + options, + value, + onChange, +}: { + type: FieldType; + options?: FieldOption[]; + value: unknown; + onChange: (val: unknown) => void; +}) { + switch (type) { + case FieldType.BOOLEAN: + return ( + + ); + + case FieldType.NUMBER: + return ( + + onChange(e.target.value === "" ? undefined : Number(e.target.value)) + } + placeholder="No default" + className={INPUT_CLS} + /> + ); + + case FieldType.DATE: + return ( + + onChange(e.target.value === "" ? undefined : e.target.value) + } + className={INPUT_CLS} + /> + ); + + case FieldType.SELECT: + return ( + + ); + + case FieldType.MULTI_SELECT: + return ( + + ); + + case FieldType.URL: + return ( + + onChange(e.target.value === "" ? undefined : e.target.value) + } + placeholder="https://..." + className={INPUT_CLS} + /> + ); + + case FieldType.EMAIL: + return ( + + onChange(e.target.value === "" ? undefined : e.target.value) + } + placeholder="name@example.com" + className={INPUT_CLS} + /> + ); + + case FieldType.TEXTAREA: + return ( +