diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 8ee077a..be5b7c4 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,9 +1,9 @@ import path from "path"; import type { NextConfig } from "next"; -import { withSentryConfig } from "@sentry/nextjs"; const nextConfig: NextConfig = { output: "standalone", + devIndicators: false, experimental: { optimizePackageImports: ["recharts", "date-fns"], }, @@ -45,10 +45,20 @@ const nextConfig: NextConfig = { }, }; -export default withSentryConfig(nextConfig, { - silent: true, - sourcemaps: { - disable: true, - }, - telemetry: false, -}); +// Only wrap with Sentry in production — the worker.js crash in dev mode +// (vendor-chunks/lib/worker.js MODULE_NOT_FOUND) makes the dev server unstable +// Sentry only in production — dynamic import avoids side effects in dev +let exportedConfig: NextConfig = nextConfig; +if (process.env.NODE_ENV === "production") { + try { + const { withSentryConfig } = require("@sentry/nextjs"); + exportedConfig = withSentryConfig(nextConfig, { + silent: true, + sourcemaps: { disable: true }, + telemetry: false, + }); + } catch { + // Sentry not available — use raw config + } +} +export default exportedConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 6d38115..714d174 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,9 @@ "test:e2e": "playwright test" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@node-rs/argon2": "^2.0.2", "@planarchy/api": "workspace:*", "@planarchy/application": "workspace:*", diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json index 858c56e..87ff8bf 100644 --- a/apps/web/public/manifest.json +++ b/apps/web/public/manifest.json @@ -1,6 +1,6 @@ { - "name": "Planarchy — Resource Planning", - "short_name": "Planarchy", + "name": "CapaKraken — Resource & Capacity Planning", + "short_name": "CapaKraken", "description": "Resource planning and project staffing for 3D production", "start_url": "/dashboard", "display": "standalone", diff --git a/apps/web/src/app/(app)/admin/activity-log/page.tsx b/apps/web/src/app/(app)/admin/activity-log/page.tsx new file mode 100644 index 0000000..292f6f6 --- /dev/null +++ b/apps/web/src/app/(app)/admin/activity-log/page.tsx @@ -0,0 +1,5 @@ +import { ActivityLogClient } from "~/components/admin/ActivityLogClient.js"; + +export default function ActivityLogPage() { + return ; +} 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..fd453eb --- /dev/null +++ b/apps/web/src/app/(app)/admin/dispo-imports/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function DispoImportRedirect() { + redirect("/admin/imports?tab=dispo"); +} diff --git a/apps/web/src/app/(app)/admin/imports/page.tsx b/apps/web/src/app/(app)/admin/imports/page.tsx new file mode 100644 index 0000000..21ed33c --- /dev/null +++ b/apps/web/src/app/(app)/admin/imports/page.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useState } from "react"; +import dynamic from "next/dynamic"; +import { useSearchParams } from "next/navigation"; + +const DispoImportClient = dynamic( + () => import("~/components/admin/DispoImportClient.js").then((m) => m.DispoImportClient), + { loading: () =>
}, +); + +const BatchSkillImport = dynamic( + () => import("~/components/admin/BatchSkillImport.js").then((m) => m.BatchSkillImport), + { loading: () =>
}, +); + +type Tab = "dispo" | "skills"; + +const TABS: { key: Tab; label: string; description: string }[] = [ + { key: "dispo", label: "Dispo Import", description: "Import planning data from Dispo V2 workbooks" }, + { key: "skills", label: "Skill Matrix", description: "Import skill matrices from XLSX files" }, +]; + +export default function ImportsPage() { + const searchParams = useSearchParams(); + const initialTab = (searchParams.get("tab") as Tab) ?? "dispo"; + const [activeTab, setActiveTab] = useState(initialTab); + + return ( +
+
+
+

Data Import

+

Import planning data and skill matrices

+
+
+ + {/* Tab bar */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Tab content */} +
+ {activeTab === "dispo" && } + {activeTab === "skills" && } +
+
+ ); +} diff --git a/apps/web/src/app/(app)/admin/skill-import/page.tsx b/apps/web/src/app/(app)/admin/skill-import/page.tsx index a3f2d05..f191481 100644 --- a/apps/web/src/app/(app)/admin/skill-import/page.tsx +++ b/apps/web/src/app/(app)/admin/skill-import/page.tsx @@ -1,5 +1,5 @@ -import { BatchSkillImport } from "~/components/admin/BatchSkillImport.js"; +import { redirect } from "next/navigation"; -export default function BatchSkillImportPage() { - return ; +export default function SkillImportRedirect() { + redirect("/admin/imports?tab=skills"); } diff --git a/apps/web/src/app/(app)/admin/vacations/page.tsx b/apps/web/src/app/(app)/admin/vacations/page.tsx index fdf5a96..8ddf031 100644 --- a/apps/web/src/app/(app)/admin/vacations/page.tsx +++ b/apps/web/src/app/(app)/admin/vacations/page.tsx @@ -1,7 +1,7 @@ import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js"; import { EntitlementManager } from "~/components/vacations/EntitlementManager.js"; -export const metadata = { title: "Vacation Management — plANARCHY" }; +export const metadata = { title: "Vacation Management — CapaKraken" }; export default function AdminVacationsPage() { return ( diff --git a/apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx b/apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx index 57f5ef0..a57b7ae 100644 --- a/apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx +++ b/apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx @@ -1,5 +1,5 @@ -import { SkillMarketplace } from "~/components/analytics/SkillMarketplace.js"; +import { redirect } from "next/navigation"; export default function SkillMarketplacePage() { - return ; + redirect("/analytics/skills?tab=search"); } diff --git a/apps/web/src/app/(app)/analytics/skills/page.tsx b/apps/web/src/app/(app)/analytics/skills/page.tsx index e2fbbeb..225165f 100644 --- a/apps/web/src/app/(app)/analytics/skills/page.tsx +++ b/apps/web/src/app/(app)/analytics/skills/page.tsx @@ -1,5 +1,5 @@ -import { SkillsAnalytics } from "~/components/analytics/SkillsAnalytics.js"; +import { SkillsHub } from "~/components/analytics/SkillsHub.js"; -export default function SkillsAnalyticsPage() { - return ; +export default function SkillsHubPage() { + return ; } diff --git a/apps/web/src/app/(app)/estimates/EstimatesClient.tsx b/apps/web/src/app/(app)/estimates/EstimatesClient.tsx index d92e1ac..205db5e 100644 --- a/apps/web/src/app/(app)/estimates/EstimatesClient.tsx +++ b/apps/web/src/app/(app)/estimates/EstimatesClient.tsx @@ -466,7 +466,7 @@ export function EstimatesClient() { No estimates yet

- Start with the wizard to create a connected estimate from plANARCHY data. + Start with the wizard to create a connected estimate from CapaKraken data.

) : ( diff --git a/apps/web/src/app/(app)/projects/ProjectsClient.tsx b/apps/web/src/app/(app)/projects/ProjectsClient.tsx index 6f99a20..f05d6e3 100644 --- a/apps/web/src/app/(app)/projects/ProjectsClient.tsx +++ b/apps/web/src/app/(app)/projects/ProjectsClient.tsx @@ -27,6 +27,7 @@ import { useViewPrefs } from "~/hooks/useViewPrefs.js"; import { useRowOrder } from "~/hooks/useRowOrder.js"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js"; +import { ShoringBadge } from "~/components/projects/ShoringIndicator.js"; import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js"; @@ -453,6 +454,12 @@ export function ProjectsClient() { )} ); + case "shoring": + return ( + + + + ); case "responsible": return —; default: diff --git a/apps/web/src/app/(app)/projects/[id]/page.tsx b/apps/web/src/app/(app)/projects/[id]/page.tsx index 38175f1..e1be42d 100644 --- a/apps/web/src/app/(app)/projects/[id]/page.tsx +++ b/apps/web/src/app/(app)/projects/[id]/page.tsx @@ -10,6 +10,7 @@ import { ProjectAssignmentsTable } from "~/components/projects/ProjectAssignment import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { CoverArtSection } from "~/components/projects/CoverArtSection.js"; +import { ShoringIndicator } from "~/components/projects/ShoringIndicator.js"; const EDIT_ROLES = new Set(["ADMIN", "MANAGER"]); @@ -133,6 +134,9 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro {/* Budget status card (client component) */} + {/* Nearshore ratio indicator (client component) */} + + {/* Assignments table (client component with delete action) */} diff --git a/apps/web/src/app/(app)/projects/[id]/scenario/page.tsx b/apps/web/src/app/(app)/projects/[id]/scenario/page.tsx index 2bc0aeb..3efee4e 100644 --- a/apps/web/src/app/(app)/projects/[id]/scenario/page.tsx +++ b/apps/web/src/app/(app)/projects/[id]/scenario/page.tsx @@ -50,8 +50,8 @@ export default async function ScenarioPage({ params }: ScenarioPageProps) {
); diff --git a/apps/web/src/app/(app)/resources/ResourcesClient.tsx b/apps/web/src/app/(app)/resources/ResourcesClient.tsx index 6eea6bc..086663b 100644 --- a/apps/web/src/app/(app)/resources/ResourcesClient.tsx +++ b/apps/web/src/app/(app)/resources/ResourcesClient.tsx @@ -1001,7 +1001,7 @@ export function ResourcesClient() { sortField={sortField} sortDir={sortDir} onSort={toggle} - tooltip="Unique employee identifier used across all plANARCHY records." + tooltip="Unique employee identifier used across all CapaKraken records." /> ); case "displayName": diff --git a/apps/web/src/app/(app)/resources/[id]/page.tsx b/apps/web/src/app/(app)/resources/[id]/page.tsx index 7d0d6ec..78cea27 100644 --- a/apps/web/src/app/(app)/resources/[id]/page.tsx +++ b/apps/web/src/app/(app)/resources/[id]/page.tsx @@ -9,9 +9,9 @@ export async function generateMetadata( try { const trpc = await createCaller(); const resource = await trpc.resource.getById({ id }); - return { title: `${resource.displayName} — Resources | plANARCHY` }; + return { title: `${resource.displayName} — Resources | CapaKraken` }; } catch { - return { title: "Resource — plANARCHY" }; + return { title: "Resource — CapaKraken" }; } } 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

diff --git a/apps/web/src/app/(app)/vacations/my/page.tsx b/apps/web/src/app/(app)/vacations/my/page.tsx index c5c5279..554c112 100644 --- a/apps/web/src/app/(app)/vacations/my/page.tsx +++ b/apps/web/src/app/(app)/vacations/my/page.tsx @@ -1,6 +1,6 @@ import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js"; -export const metadata = { title: "My Vacations — plANARCHY" }; +export const metadata = { title: "My Vacations — CapaKraken" }; export default function MyVacationsPage() { return ; diff --git a/apps/web/src/app/auth/signin/page.tsx b/apps/web/src/app/auth/signin/page.tsx index 3eb9acf..a73aea2 100644 --- a/apps/web/src/app/auth/signin/page.tsx +++ b/apps/web/src/app/auth/signin/page.tsx @@ -37,7 +37,7 @@ export default function SignInPage() {
- plANARCHY Control Center + CapaKraken Control Center

Resource planning that stays readable under pressure. @@ -66,7 +66,7 @@ export default function SignInPage() {

Welcome Back

-

Sign in to plANARCHY

+

Sign in to CapaKraken

Resource Planning, staffing, and forecasting.

diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index f1cff42..be1cd13 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -19,23 +19,23 @@ const displayFont = Manrope({ export const metadata: Metadata = { metadataBase: new URL("https://planarchy.hartmut-noerenberg.com"), - title: "plANARCHY — Resource Planning", + title: "CapaKraken — Resource & Capacity Planning", description: "Interactive resource planning and project staffing tool", manifest: "/manifest.json", appleWebApp: { capable: true, statusBarStyle: "default", - title: "Planarchy", + title: "CapaKraken", }, openGraph: { - title: "plANARCHY — Resource Planning", + title: "CapaKraken — Resource & Capacity Planning", description: "Estimates, staffing, chargeability, and timelines in one workspace.", - images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "plANARCHY Logo" }], + images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "CapaKraken Logo" }], type: "website", }, twitter: { card: "summary_large_image", - title: "plANARCHY — Resource Planning", + title: "CapaKraken — Resource & Capacity Planning", description: "Estimates, staffing, chargeability, and timelines in one workspace.", images: ["/og-image.png"], }, diff --git a/apps/web/src/components/admin/ActivityLogClient.tsx b/apps/web/src/components/admin/ActivityLogClient.tsx new file mode 100644 index 0000000..a37c5bc --- /dev/null +++ b/apps/web/src/components/admin/ActivityLogClient.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { useState, useMemo, useCallback, useEffect } from "react"; +import Link from "next/link"; +import type { Route } from "next"; +import { trpc } from "~/lib/trpc/client.js"; + +// ─── Constants ────────────────────────────────────────────────────────────── + +const ACTION_BADGES: Record = { + CREATE: { label: "Create", className: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400" }, + UPDATE: { label: "Update", className: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400" }, + DELETE: { label: "Delete", className: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400" }, + SHIFT: { label: "Shift", className: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400" }, + IMPORT: { label: "Import", className: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400" }, +}; + +const ENTITY_TYPE_OPTIONS = [ + "Project", + "Resource", + "Allocation", + "Blueprint", + "Vacation", + "Role", + "Estimate", + "EstimateVersion", + "ScopeItem", + "DemandLine", + "Comment", +]; + +const ACTION_OPTIONS = ["CREATE", "UPDATE", "DELETE", "SHIFT", "IMPORT"]; + +const ENTITY_LINKS: Record string> = { + Project: (id) => `/projects/${id}`, + Resource: (id) => `/resources/${id}`, + Allocation: (id) => `/allocations?allocationId=${id}`, + Blueprint: (_id) => `/admin/blueprints`, + Vacation: (_id) => `/vacations`, + Role: (_id) => `/roles`, + Estimate: (id) => `/estimates/${id}`, +}; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function relativeTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - new Date(date).getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHr = Math.floor(diffMin / 60); + const diffDays = Math.floor(diffHr / 24); + + if (diffSec < 60) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHr < 24) return `${diffHr}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return new Date(date).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); +} + +function userInitials(name: string | null | undefined, email: string): string { + if (name) { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase(); + return name.slice(0, 2).toUpperCase(); + } + return email.slice(0, 2).toUpperCase(); +} + +type DiffEntry = { old: unknown; new: unknown }; +type Changes = { + before?: Record; + after?: Record; + diff?: Record; + metadata?: Record; +}; + +function parseChanges(changes: unknown): Changes { + if (!changes || typeof changes !== "object") return {}; + return changes as Changes; +} + +function formatValue(val: unknown): string { + if (val === null || val === undefined) return "(empty)"; + if (typeof val === "boolean") return val ? "Yes" : "No"; + if (typeof val === "object") return JSON.stringify(val); + return String(val); +} + +// ─── Sub-components ───────────────────────────────────────────────────────── + +function ActionBadge({ action }: { action: string }) { + const badge = ACTION_BADGES[action] ?? { label: action, className: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" }; + return ( + + {badge.label} + + ); +} + +function DiffView({ changes }: { changes: Changes }) { + const diff = changes.diff; + if (!diff || Object.keys(diff).length === 0) { + return

No field-level diff available.

; + } + + return ( +
+ {Object.entries(diff).map(([field, { old: oldVal, new: newVal }]) => ( +
+ {field} + + {formatValue(oldVal)} + + + + {formatValue(newVal)} + +
+ ))} +
+ ); +} + +function ExpandedDiff({ entryId }: { entryId: string }) { + const { data, isLoading } = trpc.auditLog.getById.useQuery( + { id: entryId }, + { staleTime: 300_000 }, + ); + + if (isLoading) { + return ( +
+
+
+ ); + } + + const changes = parseChanges((data as any)?.changes); + return ( +
+ +
+ ); +} + +function SummaryCards({ summary }: { summary: { byEntityType: Record; total: number } }) { + const sorted = useMemo(() => { + return Object.entries(summary.byEntityType) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5); + }, [summary.byEntityType]); + + return ( +
+
+

Total (7d)

+

{summary.total}

+
+ {sorted.map(([type, count]) => ( +
+

{type}

+

{count}

+
+ ))} +
+ ); +} + +// ─── Main Component ───────────────────────────────────────────────────────── + +export function ActivityLogClient() { + // Filters + const [entityType, setEntityType] = useState(""); + const [action, setAction] = useState(""); + const [userId, setUserId] = useState(""); + const [search, setSearch] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + + // Expanded entry + const [expandedId, setExpandedId] = useState(null); + + // Summary (last 7 days) + const sevenDaysAgo = useMemo(() => { + const d = new Date(); + d.setDate(d.getDate() - 7); + return d; + }, []); + + const { data: summary } = trpc.auditLog.getActivitySummary.useQuery( + { startDate: sevenDaysAgo }, + { staleTime: 60_000 }, + ); + + // Users for filter dropdown + type UserListItem = { id: string; name: string | null; email: string }; + const { data: users = [] } = trpc.user.list.useQuery(undefined, { staleTime: 300_000 }) as { data: UserListItem[] }; + + // Build query input + const queryInput = useMemo(() => { + const input: Record = { limit: 50 }; + if (entityType) input.entityType = entityType; + if (action) input.action = action; + if (userId) input.userId = userId; + if (search) input.search = search; + if (startDate) input.startDate = new Date(startDate); + if (endDate) input.endDate = new Date(endDate + "T23:59:59"); + return input; + }, [entityType, action, userId, search, startDate, endDate]); + + const [cursor, setCursor] = useState(undefined); + const [allEntries, setAllEntries] = useState([]); + const [hasNextPage, setHasNextPage] = useState(false); + + const { data, isLoading, isFetching } = (trpc.auditLog.list as any).useQuery( + { ...queryInput, ...(cursor ? { cursor } : {}) }, + { staleTime: 30_000, keepPreviousData: true }, + ) as { data: { items: any[]; nextCursor?: string } | undefined; isLoading: boolean; isFetching: boolean }; + + // Append new page results + useEffect(() => { + if (!data) return; + if (!cursor) { + // First page or filter change — replace + setAllEntries(data.items); + } else { + // Subsequent page — append + setAllEntries((prev) => [...prev, ...data.items]); + } + setHasNextPage(!!data.nextCursor); + }, [data, cursor]); + + // Reset cursor when filters change + useEffect(() => { + setCursor(undefined); + setAllEntries([]); + }, [entityType, action, userId, search, startDate, endDate]); + + function loadMore() { + if (data?.nextCursor) { + setCursor(data.nextCursor); + } + } + + const toggleExpand = useCallback((id: string) => { + setExpandedId((prev) => (prev === id ? null : id)); + }, []); + + const totalCount = summary?.total ?? 0; + + return ( +
+ {/* Header */} +
+

Activity Log

+

+ {totalCount.toLocaleString()} changes recorded in the last 7 days +

+
+ + {/* Summary Cards */} + {summary && } + + {/* Filter Bar */} +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + setStartDate(e.target.value)} + className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-gray-200" + /> +
+ +
+ + setEndDate(e.target.value)} + className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-gray-200" + /> +
+ +
+ + setSearch(e.target.value)} + className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-gray-200" + /> +
+ + +
+ + {/* Timeline List */} +
+ {isLoading && ( +
+
+
+ )} + + {!isLoading && allEntries.length === 0 && ( +
+ + + +

No activity found

+

Try adjusting your filters or date range.

+
+ )} + + {allEntries.map((entry) => { + const isExpanded = expandedId === entry.id; + const entityLink = ENTITY_LINKS[entry.entityType]?.(entry.entityId); + + return ( +
+ + + {/* Expanded Diff — fetched on demand */} + {isExpanded && } +
+ ); + })} + + {/* Load More */} + {hasNextPage && ( +
+ +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/admin/CalculationRulesClient.tsx b/apps/web/src/components/admin/CalculationRulesClient.tsx index 8811190..a9c4879 100644 --- a/apps/web/src/components/admin/CalculationRulesClient.tsx +++ b/apps/web/src/components/admin/CalculationRulesClient.tsx @@ -1,6 +1,8 @@ "use client"; import { useState } from "react"; +import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -71,6 +73,7 @@ const emptyRule: EditingRule = { export function CalculationRulesClient() { const [editing, setEditing] = useState(null); + const [confirmDeleteRule, setConfirmDeleteRule] = useState(null); const [error, setError] = useState(null); const utils = trpc.useUtils(); @@ -219,7 +222,7 @@ export function CalculationRulesClient() {
{/* ── Edit/Create Modal ── */} - {editing && ( -
-
+ setEditing(null)} maxWidth="max-w-lg"> + {editing && (<> +

{editing.id ? "Edit Rule" : "New Rule"}

@@ -363,8 +366,22 @@ export function CalculationRulesClient() { {createMut.isPending || updateMut.isPending ? "Saving..." : "Save"}
-
-
+
+ )} + + + {confirmDeleteRule && ( + { + deleteMut.mutate({ id: confirmDeleteRule }); + setConfirmDeleteRule(null); + }} + onCancel={() => setConfirmDeleteRule(null)} + /> )}
); diff --git a/apps/web/src/components/admin/ClientsAdminClient.tsx b/apps/web/src/components/admin/ClientsAdminClient.tsx index d26c6fa..043b6fe 100644 --- a/apps/web/src/components/admin/ClientsAdminClient.tsx +++ b/apps/web/src/components/admin/ClientsAdminClient.tsx @@ -1,9 +1,31 @@ "use client"; -import { useState } from "react"; +import { + closestCenter, + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + type ClientRow = { id: string; name: string; @@ -11,279 +33,1049 @@ type ClientRow = { parentId: string | null; sortOrder: number; isActive: boolean; + tags: string[]; + _count?: { children: number; projects: number }; }; -type ClientNode = ClientRow & { children: ClientNode[] }; - -type EditingClient = { - id?: string; - name: string; - code: string; - parentId: string; - sortOrder: number; +type TreeNode = ClientRow & { + children: TreeNode[]; + depth: number; }; -function ClientTreeNode({ - node, - onEdit, - onAddChild, - depth = 0, +// --------------------------------------------------------------------------- +// Tree helpers +// --------------------------------------------------------------------------- + +function buildTree(clients: ClientRow[]): TreeNode[] { + const map = new Map(); + const roots: TreeNode[] = []; + + // First pass: create nodes + for (const c of clients) { + map.set(c.id, { ...c, children: [], depth: 0 }); + } + + // Second pass: link parents + for (const c of clients) { + const node = map.get(c.id)!; + if (c.parentId && map.has(c.parentId)) { + map.get(c.parentId)!.children.push(node); + } else { + roots.push(node); + } + } + + // Third pass: set depths and sort children + function setDepths(nodes: TreeNode[], depth: number) { + for (const n of nodes) { + n.depth = depth; + n.children.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)); + setDepths(n.children, depth + 1); + } + } + roots.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)); + setDepths(roots, 0); + + return roots; +} + +/** Flatten tree into display order, respecting collapsed state. */ +function flattenTree(nodes: TreeNode[], collapsedIds: Set): TreeNode[] { + const result: TreeNode[] = []; + function walk(list: TreeNode[]) { + for (const n of list) { + result.push(n); + if (n.children.length > 0 && !collapsedIds.has(n.id)) { + walk(n.children); + } + } + } + walk(nodes); + return result; +} + +/** Get all descendant IDs of a node (recursive). */ +function getDescendantIds(clientId: string, clients: ClientRow[]): Set { + const ids = new Set(); + function collect(parentId: string) { + for (const c of clients) { + if (c.parentId === parentId && !ids.has(c.id)) { + ids.add(c.id); + collect(c.id); + } + } + } + collect(clientId); + return ids; +} + +/** Get all ancestor IDs of a node (walk up). */ +function getAncestorIds(clientId: string, clientMap: Map): Set { + const ids = new Set(); + let current = clientMap.get(clientId); + while (current?.parentId) { + ids.add(current.parentId); + current = clientMap.get(current.parentId); + } + return ids; +} + +// --------------------------------------------------------------------------- +// Tag color palette -- deterministic color from tag name hash +// --------------------------------------------------------------------------- + +const TAG_COLORS = [ + { bg: "bg-purple-100 dark:bg-purple-900/40", text: "text-purple-700 dark:text-purple-300", border: "border-purple-200 dark:border-purple-700" }, + { bg: "bg-emerald-100 dark:bg-emerald-900/40", text: "text-emerald-700 dark:text-emerald-300", border: "border-emerald-200 dark:border-emerald-700" }, + { bg: "bg-amber-100 dark:bg-amber-900/40", text: "text-amber-700 dark:text-amber-300", border: "border-amber-200 dark:border-amber-700" }, + { bg: "bg-rose-100 dark:bg-rose-900/40", text: "text-rose-700 dark:text-rose-300", border: "border-rose-200 dark:border-rose-700" }, + { bg: "bg-sky-100 dark:bg-sky-900/40", text: "text-sky-700 dark:text-sky-300", border: "border-sky-200 dark:border-sky-700" }, + { bg: "bg-indigo-100 dark:bg-indigo-900/40", text: "text-indigo-700 dark:text-indigo-300", border: "border-indigo-200 dark:border-indigo-700" }, + { bg: "bg-teal-100 dark:bg-teal-900/40", text: "text-teal-700 dark:text-teal-300", border: "border-teal-200 dark:border-teal-700" }, + { bg: "bg-orange-100 dark:bg-orange-900/40", text: "text-orange-700 dark:text-orange-300", border: "border-orange-200 dark:border-orange-700" }, +]; + +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +} + +function getTagColor(tag: string) { + return TAG_COLORS[hashString(tag) % TAG_COLORS.length]!; +} + +// --------------------------------------------------------------------------- +// Tag pill component +// --------------------------------------------------------------------------- + +function TagPill({ + tag, + onRemove, }: { - node: ClientNode; - onEdit: (c: ClientRow) => void; - onAddChild: (parentId: string) => void; - depth?: number; + tag: string; + onRemove?: () => void; }) { - const [expanded, setExpanded] = useState(depth < 1); - const hasChildren = node.children.length > 0; + const color = getTagColor(tag); + return ( + + {tag} + {onRemove && ( + + )} + + ); +} + +// --------------------------------------------------------------------------- +// Inline tag adder with auto-suggest +// --------------------------------------------------------------------------- + +function TagAdder({ + existingTags, + allKnownTags, + onAdd, + onClose, +}: { + existingTags: string[]; + allKnownTags: string[]; + onAdd: (tag: string) => void; + onClose: () => void; +}) { + const [value, setValue] = useState(""); + const inputRef = useRef(null); + const [showSuggestions, setShowSuggestions] = useState(false); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const suggestions = useMemo(() => { + if (!value.trim()) return allKnownTags.filter((t) => !existingTags.includes(t)).slice(0, 8); + const lower = value.toLowerCase(); + return allKnownTags + .filter((t) => t.toLowerCase().includes(lower) && !existingTags.includes(t)) + .slice(0, 8); + }, [value, allKnownTags, existingTags]); + + function submit(tag: string) { + const trimmed = tag.trim(); + if (!trimmed || existingTags.includes(trimmed)) return; + onAdd(trimmed); + setValue(""); + } return ( -
-
- {hasChildren ? ( - - ) : ( - - )} - - {node.name} - {node.code && [{node.code}]} - - {!node.isActive && inactive} -
- - +
+ { + setValue(e.target.value); + setShowSuggestions(true); + }} + onFocus={() => setShowSuggestions(true)} + onBlur={() => { + // Delay to allow click on suggestion + setTimeout(() => { + setShowSuggestions(false); + if (!value.trim()) onClose(); + }, 200); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + submit(value); + } + if (e.key === "Escape") { + onClose(); + } + }} + placeholder="Tag..." + className="w-24 px-2 py-0.5 text-xs rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-brand-400" + /> + {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((s) => ( + + ))}
-
- {expanded && node.children.map((child) => ( - - ))} + )}
); } +// --------------------------------------------------------------------------- +// Drag handle icon +// --------------------------------------------------------------------------- + +function GripIcon({ className }: { className?: string }) { + return ( + + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Chevron icon for expand/collapse +// --------------------------------------------------------------------------- + +function ChevronIcon({ expanded, className }: { expanded: boolean; className?: string }) { + return ( + + + + ); +} + +// --------------------------------------------------------------------------- +// Parent selector dropdown +// --------------------------------------------------------------------------- + +function ParentSelector({ + clients, + currentId, + currentParentId, + onChange, +}: { + clients: ClientRow[]; + currentId?: string; + currentParentId: string | null; + onChange: (parentId: string | null) => void; +}) { + // Exclude self and all descendants to prevent circular refs + const excludeIds = useMemo(() => { + if (!currentId) return new Set(); + const ids = getDescendantIds(currentId, clients); + ids.add(currentId); + return ids; + }, [currentId, clients]); + + const options = useMemo(() => { + return clients + .filter((c) => !excludeIds.has(c.id) && c.isActive) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [clients, excludeIds]); + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Sortable client card +// --------------------------------------------------------------------------- + +function SortableClientCard({ + client, + allClients, + allKnownTags, + onUpdateName, + onUpdateSortOrder, + onUpdateTags, + onUpdateParent, + onDelete, + onToggleExpand, + isExpanded, + isDragOverlay, +}: { + client: TreeNode; + allClients: ClientRow[]; + allKnownTags: string[]; + onUpdateName: (id: string, name: string) => void; + onUpdateSortOrder: (id: string, sortOrder: number) => void; + onUpdateTags: (id: string, tags: string[]) => void; + onUpdateParent: (id: string, parentId: string | null) => void; + onDelete: (id: string) => void; + onToggleExpand: (id: string) => void; + isExpanded: boolean; + isDragOverlay?: boolean; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: client.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const [editingName, setEditingName] = useState(false); + const [nameValue, setNameValue] = useState(client.name); + const [editingSortOrder, setEditingSortOrder] = useState(false); + const [sortOrderValue, setSortOrderValue] = useState(client.sortOrder); + const [addingTag, setAddingTag] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [showParentSelector, setShowParentSelector] = useState(false); + const nameInputRef = useRef(null); + const sortInputRef = useRef(null); + + useEffect(() => { + if (editingName) nameInputRef.current?.focus(); + }, [editingName]); + + useEffect(() => { + if (editingSortOrder) sortInputRef.current?.focus(); + }, [editingSortOrder]); + + // Sync external updates + useEffect(() => { + setNameValue(client.name); + }, [client.name]); + + useEffect(() => { + setSortOrderValue(client.sortOrder); + }, [client.sortOrder]); + + function saveName() { + const trimmed = nameValue.trim(); + if (trimmed && trimmed !== client.name) { + onUpdateName(client.id, trimmed); + } else { + setNameValue(client.name); + } + setEditingName(false); + } + + function saveSortOrder() { + if (sortOrderValue !== client.sortOrder) { + onUpdateSortOrder(client.id, sortOrderValue); + } + setEditingSortOrder(false); + } + + const hasChildren = (client._count?.children ?? client.children.length) > 0; + const childCount = client._count?.children ?? client.children.length; + const depth = client.depth; + + // Nesting depth styling: alternating subtle backgrounds + const depthBg = depth > 0 + ? depth % 2 === 1 + ? "bg-gray-50/50 dark:bg-gray-800/80" + : "bg-white dark:bg-gray-800/60" + : "bg-white dark:bg-gray-800"; + + const cardClasses = [ + "flex items-center gap-3 px-4 py-3 rounded-xl border transition-all group", + isDragOverlay + ? "bg-white dark:bg-gray-800 border-brand-500 shadow-xl scale-[1.02] ring-2 ring-brand-500" + : isDragging + ? "opacity-30 bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700" + : `${depthBg} border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-sm`, + ].join(" "); + + // Indent padding: 0 for root, 32px per level + const indentPx = depth * 32; + + // Find parent name for display + const parentClient = client.parentId + ? allClients.find((c) => c.id === client.parentId) + : null; + + return ( +
0 ? "relative" : undefined} + > + {/* Tree connector line */} + {depth > 0 && !isDragOverlay && ( +
+ )} + +
+ {/* Expand/collapse toggle */} +
+ {hasChildren ? ( + + ) : ( + + )} +
+ + {/* Drag handle */} + + + {/* Name + parent info */} +
+ {editingName ? ( + setNameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") saveName(); + if (e.key === "Escape") { + setNameValue(client.name); + setEditingName(false); + } + }} + onBlur={saveName} + className="w-full px-2 py-1 -ml-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + /> + ) : ( +
+ + + {/* Children count badge */} + {hasChildren && ( + + {childCount} + + )} +
+ )} +
+ + {/* Parent selector */} +
+ {showParentSelector ? ( +
+ { + if (newParentId !== client.parentId) { + onUpdateParent(client.id, newParentId); + } + setShowParentSelector(false); + }} + /> + +
+ ) : ( + + )} +
+ + {/* Tags */} +
+ {client.tags.map((tag) => ( + { + onUpdateTags(client.id, client.tags.filter((t) => t !== tag)); + }} + /> + ))} + {addingTag ? ( + { + onUpdateTags(client.id, [...client.tags, tag]); + }} + onClose={() => setAddingTag(false)} + /> + ) : ( + + )} +
+ + {/* Sort order */} +
+ {editingSortOrder ? ( + setSortOrderValue(parseInt(e.target.value) || 0)} + onKeyDown={(e) => { + if (e.key === "Enter") saveSortOrder(); + if (e.key === "Escape") { + setSortOrderValue(client.sortOrder); + setEditingSortOrder(false); + } + }} + onBlur={saveSortOrder} + className="w-full px-1 py-0.5 text-xs text-center rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 focus:outline-none focus:ring-1 focus:ring-brand-400" + /> + ) : ( + + )} +
+ + {/* Delete */} +
+ {confirmDelete ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + export function ClientsAdminClient() { - const [editing, setEditing] = useState(null); const [search, setSearch] = useState(""); + const [newName, setNewName] = useState(""); + const [newParentId, setNewParentId] = useState(null); const [error, setError] = useState(null); + const [activeId, setActiveId] = useState(null); + const [collapsedIds, setCollapsedIds] = useState>(new Set()); + const newInputRef = useRef(null); const utils = trpc.useUtils(); - const { data: tree, isLoading } = trpc.clientEntity.getTree.useQuery(); - const { data: flatList } = trpc.clientEntity.list.useQuery(); + const { data: rawList, isLoading } = trpc.clientEntity.list.useQuery(); + + const clients = useMemo(() => { + return ((rawList ?? []) as unknown as ClientRow[]).slice().sort( + (a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name), + ); + }, [rawList]); + + // Map for fast lookup + const clientMap = useMemo(() => { + const m = new Map(); + for (const c of clients) m.set(c.id, c); + return m; + }, [clients]); + + // All known tags across all clients for auto-suggest + const allKnownTags = useMemo(() => { + const set = new Set(); + for (const c of clients) { + for (const t of c.tags ?? []) set.add(t); + } + return Array.from(set).sort(); + }, [clients]); + + // Build tree, then flatten with search filtering + const tree = useMemo(() => buildTree(clients), [clients]); + + // When searching: filter matching clients and include their ancestors + const displayNodes = useMemo(() => { + if (!search.trim()) { + return flattenTree(tree, collapsedIds); + } + + const lower = search.toLowerCase(); + // Find IDs of clients matching the search + const matchingIds = new Set(); + for (const c of clients) { + if ( + c.name.toLowerCase().includes(lower) || + (c.code ?? "").toLowerCase().includes(lower) || + (c.tags ?? []).some((t) => t.toLowerCase().includes(lower)) + ) { + matchingIds.add(c.id); + } + } + + // Also include all ancestors of matching clients so tree path is visible + const visibleIds = new Set(matchingIds); + for (const id of matchingIds) { + const ancestors = getAncestorIds(id, clientMap); + for (const aid of ancestors) visibleIds.add(aid); + } + + // Build tree from only visible clients + const visibleClients = clients.filter((c) => visibleIds.has(c.id)); + const filteredTree = buildTree(visibleClients); + // When searching, show all nodes expanded (don't respect collapsed state) + return flattenTree(filteredTree, new Set()); + }, [search, tree, collapsedIds, clients, clientMap]); + + // Drag sensors + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + // Mutations + const invalidateAll = useCallback(() => { + void utils.clientEntity.list.invalidate(); + void utils.clientEntity.getTree.invalidate(); + }, [utils]); const createMut = trpc.clientEntity.create.useMutation({ - onSuccess: () => { void utils.clientEntity.getTree.invalidate(); void utils.clientEntity.list.invalidate(); setEditing(null); }, + onSuccess: () => { + invalidateAll(); + setNewName(""); + setNewParentId(null); + }, onError: (e) => setError(e.message), }); + const updateMut = trpc.clientEntity.update.useMutation({ - onSuccess: () => { void utils.clientEntity.getTree.invalidate(); void utils.clientEntity.list.invalidate(); setEditing(null); }, + onSuccess: invalidateAll, onError: (e) => setError(e.message), }); - const allClients = (flatList ?? []) as unknown as ClientRow[]; + const deleteMut = trpc.clientEntity.delete.useMutation({ + onSuccess: invalidateAll, + onError: (e) => setError(e.message), + }); - function openCreate(parentId?: string) { - setEditing({ name: "", code: "", parentId: parentId ?? "", sortOrder: 0 }); - setError(null); - } + const batchSortMut = trpc.clientEntity.batchUpdateSortOrder.useMutation({ + onSuccess: invalidateAll, + onError: (e) => setError(e.message), + }); - function openEdit(c: ClientRow) { - setEditing({ - id: c.id, - name: c.name, - code: c.code ?? "", - parentId: c.parentId ?? "", - sortOrder: c.sortOrder, + // Handlers + function handleCreate() { + const trimmed = newName.trim(); + if (!trimmed) return; + // Compute max sortOrder among siblings + const siblings = clients.filter((c) => + newParentId ? c.parentId === newParentId : !c.parentId, + ); + const maxSort = siblings.reduce((max, c) => Math.max(max, c.sortOrder), 0); + createMut.mutate({ + name: trimmed, + sortOrder: maxSort + 1, + ...(newParentId ? { parentId: newParentId } : {}), }); - setError(null); } - function handleSave() { - if (!editing) return; - setError(null); - - if (editing.id) { - updateMut.mutate({ - id: editing.id, - data: { - name: editing.name, - code: editing.code || undefined, - parentId: editing.parentId || undefined, - sortOrder: editing.sortOrder, - }, - }); - } else { - createMut.mutate({ - name: editing.name, - code: editing.code || undefined, - parentId: editing.parentId || undefined, - sortOrder: editing.sortOrder, - }); - } + function handleUpdateName(id: string, name: string) { + updateMut.mutate({ id, data: { name } }); } - const isPending = createMut.isPending || updateMut.isPending; - const treeNodes = (tree ?? []) as unknown as ClientNode[]; + function handleUpdateSortOrder(id: string, sortOrder: number) { + updateMut.mutate({ id, data: { sortOrder } }); + } - // Simple client-side filter on tree - function filterTree(nodes: ClientNode[], q: string): ClientNode[] { - if (!q) return nodes; - const lower = q.toLowerCase(); - return nodes.reduce((acc, node) => { - const filteredChildren = filterTree(node.children, q); - if (node.name.toLowerCase().includes(lower) || (node.code ?? "").toLowerCase().includes(lower) || filteredChildren.length > 0) { - acc.push({ ...node, children: filteredChildren }); + function handleUpdateTags(id: string, tags: string[]) { + updateMut.mutate({ id, data: { tags } }); + } + + function handleUpdateParent(id: string, parentId: string | null) { + updateMut.mutate({ id, data: { parentId } }); + } + + function handleDelete(id: string) { + deleteMut.mutate({ id }); + } + + function handleToggleExpand(id: string) { + setCollapsedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); } - return acc; - }, []); + return next; + }); } - const filteredTree = filterTree(treeNodes, search); + // Drag and drop — moves the whole subtree with the dragged client + function handleDragStart(event: DragStartEvent) { + setActiveId(event.active.id as string); + } + + function handleDragEnd(event: DragEndEvent) { + setActiveId(null); + const { active, over } = event; + if (!over || active.id === over.id) return; + + const oldIndex = displayNodes.findIndex((c) => c.id === active.id); + const newIndex = displayNodes.findIndex((c) => c.id === over.id); + if (oldIndex === -1 || newIndex === -1) return; + + // Get the dragged node and all its descendants (the subtree) + const draggedId = active.id as string; + const descendantIds = getDescendantIds(draggedId, clients); + + // Filter display nodes to only siblings at the same parent level as the dragged node + const draggedClient = clientMap.get(draggedId); + if (!draggedClient) return; + + const overClient = displayNodes[newIndex]; + if (!overClient) return; + + // Only allow reorder among siblings (same parentId) + if (draggedClient.parentId !== overClient.parentId) return; + + // Get sibling nodes in display order (excluding descendants of dragged) + const siblings = displayNodes.filter( + (c) => c.parentId === draggedClient.parentId && !descendantIds.has(c.id), + ); + + const sibOldIndex = siblings.findIndex((c) => c.id === active.id); + const sibNewIndex = siblings.findIndex((c) => c.id === over.id); + if (sibOldIndex === -1 || sibNewIndex === -1) return; + + const reordered = arrayMove(siblings, sibOldIndex, sibNewIndex); + const updates = reordered.map((c, i) => ({ id: c.id, sortOrder: i })); + + // Optimistic update + utils.clientEntity.list.setData(undefined, (prev) => { + if (!prev) return prev; + const sortMap = new Map(updates.map((u) => [u.id, u.sortOrder])); + return prev.map((c) => { + const newSort = sortMap.get(c.id); + if (newSort !== undefined) return { ...c, sortOrder: newSort }; + return c; + }) as typeof prev; + }); + + batchSortMut.mutate(updates); + } + + const activeClient = activeId + ? displayNodes.find((c) => c.id === activeId) + : null; return (
-
-
-

Clients

-

- Client hierarchy for project assignment and chargeability reporting -

-
+ {/* Header */} +
+

Clients

+

+ Manage clients for project assignment and chargeability reporting{" "} + +

+
+ + {/* Add new client -- sticky row */} +
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + placeholder="New client name..." + className="flex-1 border border-gray-300 dark:border-gray-600 rounded-xl px-4 py-2.5 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:border-transparent" + /> +
-
+ {/* Search/filter */} +
setSearch(e.target.value)} - placeholder="Search clients..." - className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64 bg-white dark:bg-gray-900 dark:text-gray-100" + placeholder="Filter by name or tag..." + className="border border-gray-300 dark:border-gray-600 rounded-xl px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-full sm:w-72 bg-white dark:bg-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" /> + {!search && clients.length > 0 && ( + + )}
+ {/* Error */} {error && ( -
+
{error} - +
)} -
- {isLoading &&
Loading...
} - {!isLoading && filteredTree.length === 0 && ( -
- {search ? "No clients match your search." : "No clients yet."} + {/* Client list */} +
+ {isLoading && ( +
Loading...
+ )} + + {!isLoading && displayNodes.length === 0 && ( +
+ {search ? "No clients match your filter." : "No clients yet. Add one above."}
)} - {filteredTree.map((node) => ( - openCreate(pid)} /> - ))} + + {!isLoading && displayNodes.length > 0 && ( + + c.id)} + strategy={verticalListSortingStrategy} + > + {displayNodes.map((client) => ( + + ))} + + + + {activeClient ? ( + {}} + onUpdateSortOrder={() => {}} + onUpdateTags={() => {}} + onUpdateParent={() => {}} + onDelete={() => {}} + onToggleExpand={() => {}} + isExpanded={false} + isDragOverlay + /> + ) : null} + + + )}
- {/* Create/Edit Modal */} - {editing && ( -
-
-
-

- {editing.id ? "Edit Client" : "Add Client"} -

- -
- -
-
- - setEditing({ ...editing, name: e.target.value })} - placeholder="e.g. BMW Group" - className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" - /> -
- -
-
- - setEditing({ ...editing, code: e.target.value })} - placeholder="BMW" - className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono" - /> -
-
- - setEditing({ ...editing, sortOrder: parseInt(e.target.value) || 0 })} - className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" - /> -
-
- -
- - -
-
- -
- - -
-
+ {/* Count */} + {!isLoading && clients.length > 0 && ( +
+ {displayNodes.length} of {clients.length} client{clients.length !== 1 ? "s" : ""} + {clients.filter((c) => c.parentId).length > 0 && ( + + ({clients.filter((c) => !c.parentId).length} top-level, {clients.filter((c) => c.parentId).length} nested) + + )}
)}
diff --git a/apps/web/src/components/admin/CountriesClient.tsx b/apps/web/src/components/admin/CountriesClient.tsx index dc8f269..d6f5c48 100644 --- a/apps/web/src/components/admin/CountriesClient.tsx +++ b/apps/web/src/components/admin/CountriesClient.tsx @@ -1,6 +1,8 @@ "use client"; import { useState } from "react"; +import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -58,6 +60,7 @@ export function CountriesClient() { const [editing, setEditing] = useState(null); const [cityName, setCityName] = useState(""); const [expandedId, setExpandedId] = useState(null); + const [confirmDeleteCity, setConfirmDeleteCity] = useState(null); const [error, setError] = useState(null); const utils = trpc.useUtils(); @@ -236,11 +239,7 @@ export function CountriesClient() { {city.name}
-
-
+ )} + + + {confirmDeleteCity && ( + { + deleteCityMut.mutate({ id: confirmDeleteCity }); + setConfirmDeleteCity(null); + }} + onCancel={() => setConfirmDeleteCity(null)} + /> )}
); diff --git a/apps/web/src/components/admin/DispoImportClient.tsx b/apps/web/src/components/admin/DispoImportClient.tsx new file mode 100644 index 0000000..65b6999 --- /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 Dispo imports +

+
+ +
+ + {/* 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/admin/EffortRulesClient.tsx b/apps/web/src/components/admin/EffortRulesClient.tsx index 32517b2..2af1de7 100644 --- a/apps/web/src/components/admin/EffortRulesClient.tsx +++ b/apps/web/src/components/admin/EffortRulesClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -87,6 +88,7 @@ export function EffortRulesClient() { const [editing, setEditing] = useState(null); const [expandedId, setExpandedId] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(null); function handleSave() { if (!editing) return; @@ -375,11 +377,7 @@ export function EffortRulesClient() { Edit
))} + + {confirmDelete && ( + { + deleteMutation.mutate({ id: confirmDelete }); + setConfirmDelete(null); + }} + onCancel={() => setConfirmDelete(null)} + /> + )}
); } diff --git a/apps/web/src/components/admin/ExperienceMultipliersClient.tsx b/apps/web/src/components/admin/ExperienceMultipliersClient.tsx index 9a3b839..fec8fce 100644 --- a/apps/web/src/components/admin/ExperienceMultipliersClient.tsx +++ b/apps/web/src/components/admin/ExperienceMultipliersClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -97,6 +98,7 @@ export function ExperienceMultipliersClient() { const [editing, setEditing] = useState(null); const [expandedId, setExpandedId] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(null); function handleSave() { if (!editing) return; @@ -422,11 +424,7 @@ export function ExperienceMultipliersClient() { Edit
))} + + {confirmDelete && ( + { + deleteMutation.mutate({ id: confirmDelete }); + setConfirmDelete(null); + }} + onCancel={() => setConfirmDelete(null)} + /> + )}
); } diff --git a/apps/web/src/components/admin/ManagementLevelsClient.tsx b/apps/web/src/components/admin/ManagementLevelsClient.tsx index 9618120..3eee28a 100644 --- a/apps/web/src/components/admin/ManagementLevelsClient.tsx +++ b/apps/web/src/components/admin/ManagementLevelsClient.tsx @@ -1,6 +1,8 @@ "use client"; import { useState } from "react"; +import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -29,6 +31,7 @@ type EditingLevel = { export function ManagementLevelsClient() { const [editingGroup, setEditingGroup] = useState(null); const [editingLevel, setEditingLevel] = useState(null); + const [confirmDeleteLevel, setConfirmDeleteLevel] = useState(null); const [error, setError] = useState(null); const utils = trpc.useUtils(); @@ -185,11 +188,7 @@ export function ManagementLevelsClient() {
{/* Group Modal */} - {editingGroup && ( -
-
+ setEditingGroup(null)} maxWidth="max-w-md"> + {editingGroup && (<>

{editingGroup.id ? "Edit Group" : "Add Group"} @@ -264,14 +262,12 @@ export function ManagementLevelsClient() { {isGroupPending ? "Saving..." : editingGroup.id ? "Update" : "Create"}

-
-
- )} + )} + {/* Level Modal */} - {editingLevel && ( -
-
+ setEditingLevel(null)} maxWidth="max-w-sm"> + {editingLevel && (<>

{editingLevel.id ? "Edit Level" : "Add Level"} @@ -316,8 +312,21 @@ export function ManagementLevelsClient() { {isLevelPending ? "Saving..." : editingLevel.id ? "Update" : "Create"}

-
-
+ )} + + + {confirmDeleteLevel && ( + { + deleteLevelMut.mutate({ id: confirmDeleteLevel }); + setConfirmDeleteLevel(null); + }} + onCancel={() => setConfirmDeleteLevel(null)} + /> )}
); diff --git a/apps/web/src/components/admin/OrgUnitsClient.tsx b/apps/web/src/components/admin/OrgUnitsClient.tsx index a5f9ab0..87c5b99 100644 --- a/apps/web/src/components/admin/OrgUnitsClient.tsx +++ b/apps/web/src/components/admin/OrgUnitsClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -195,9 +196,8 @@ export function OrgUnitsClient() {

{/* Create/Edit Modal */} - {editing && ( -
-
+ setEditing(null)} maxWidth="max-w-md"> + {editing && (<>

{editing.id ? "Edit Org Unit" : `Add ${LEVEL_LABELS[editing.level] ?? `L${editing.level}`}`} @@ -275,9 +275,8 @@ export function OrgUnitsClient() { {isPending ? "Saving..." : editing.id ? "Update" : "Create"}

-
-
- )} + )} +
); } diff --git a/apps/web/src/components/admin/RateCardsClient.tsx b/apps/web/src/components/admin/RateCardsClient.tsx index 26cd72f..ba4e446 100644 --- a/apps/web/src/components/admin/RateCardsClient.tsx +++ b/apps/web/src/components/admin/RateCardsClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { formatCents } from "~/lib/format.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -101,6 +102,8 @@ export function RateCardsClient() { const [selectedId, setSelectedId] = useState(null); const [editingCard, setEditingCard] = useState(null); const [editingLine, setEditingLine] = useState(null); + const [confirmDeleteLine, setConfirmDeleteLine] = useState(null); + const [confirmDeactivate, setConfirmDeactivate] = useState(null); const [error, setError] = useState(null); const utils = trpc.useUtils(); @@ -260,7 +263,6 @@ export function RateCardsClient() { } async function handleDeleteLine(lineId: string) { - if (!confirm("Delete this rate line?")) return; try { await deleteLineMut.mutateAsync({ lineId }); invalidateAll(); @@ -270,7 +272,6 @@ export function RateCardsClient() { } async function handleDeactivate(id: string) { - if (!confirm("Deactivate this rate card?")) return; try { await deactivateMut.mutateAsync({ id }); invalidateAll(); @@ -445,7 +446,7 @@ export function RateCardsClient() { {detail.isActive ? (
)} + + {confirmDeleteLine && ( + { + void handleDeleteLine(confirmDeleteLine); + setConfirmDeleteLine(null); + }} + onCancel={() => setConfirmDeleteLine(null)} + /> + )} + + {confirmDeactivate && ( + { + void handleDeactivate(confirmDeactivate); + setConfirmDeactivate(null); + }} + onCancel={() => setConfirmDeactivate(null)} + /> + )}
); } diff --git a/apps/web/src/components/admin/SystemSettingsClient.tsx b/apps/web/src/components/admin/SystemSettingsClient.tsx index 59d0baa..afd467c 100644 --- a/apps/web/src/components/admin/SystemSettingsClient.tsx +++ b/apps/web/src/components/admin/SystemSettingsClient.tsx @@ -96,6 +96,13 @@ export function SystemSettingsClient() { const [dalleEndpoint, setDalleEndpoint] = useState(""); const [dalleApiKey, setDalleApiKey] = useState(""); + // Gemini / Image generation settings + type ImageProvider = "dalle" | "gemini"; + const [imageProvider, setImageProvider] = useState("dalle"); + const [geminiApiKey, setGeminiApiKey] = useState(""); + const [geminiModel, setGeminiModel] = useState(""); + const [imageSaved, setImageSaved] = useState(false); + // SMTP settings const [smtpHost, setSmtpHost] = useState(""); const [smtpPort, setSmtpPort] = useState(587); @@ -144,6 +151,9 @@ export function SystemSettingsClient() { // DALL-E setDalleDeployment(settings.azureDalleDeployment ?? ""); setDalleEndpoint(settings.azureDalleEndpoint ?? ""); + // Image provider / Gemini + setImageProvider((settings.imageProvider ?? "dalle") as ImageProvider); + setGeminiModel(settings.geminiModel ?? ""); // SMTP setSmtpHost(settings.smtpHost ?? ""); setSmtpPort(settings.smtpPort ?? 587); @@ -240,6 +250,19 @@ export function SystemSettingsClient() { }, }); + const saveImageMutation = trpc.settings.updateSystemSettings.useMutation({ + onSuccess: () => { + setImageSaved(true); + setTimeout(() => setImageSaved(false), 3000); + }, + }); + + const [geminiTestResult, setGeminiTestResult] = useState<{ ok: boolean; model?: string; error?: string } | null>(null); + const testGeminiMut = trpc.settings.testGeminiConnection.useMutation({ + onSuccess: (data) => setGeminiTestResult(data as any), + onError: (err) => setGeminiTestResult({ ok: false, error: err.message }), + }); + function handleSaveSmtp() { saveSmtpMutation.mutate({ smtpHost: smtpHost || undefined, @@ -259,6 +282,19 @@ export function SystemSettingsClient() { saveTimelineMutation.mutate({ timelineUndoMaxSteps: undoMaxSteps }); } + function handleSaveImage() { + saveImageMutation.mutate({ + imageProvider, + // DALL-E fields + azureDalleDeployment: dalleDeployment || undefined, + azureDalleEndpoint: provider === "azure" && dalleEndpoint ? dalleEndpoint : undefined, + ...(dalleApiKey ? { azureDalleApiKey: dalleApiKey } : {}), + // Gemini fields + ...(geminiApiKey ? { geminiApiKey } : {}), + geminiModel: geminiModel || undefined, + }); + } + function handleSaveAnonymization() { saveAnonymizationMutation.mutate({ anonymizationEnabled, @@ -295,9 +331,6 @@ export function SystemSettingsClient() { aiTemperature: temperature, aiSummaryPrompt: summaryPrompt || undefined, ...(apiKey ? { azureOpenAiApiKey: apiKey } : {}), - azureDalleDeployment: dalleDeployment, - azureDalleEndpoint: provider === "azure" && dalleEndpoint ? dalleEndpoint : undefined, - ...(dalleApiKey ? { azureDalleApiKey: dalleApiKey } : {}), }); } @@ -1018,69 +1051,184 @@ export function SystemSettingsClient() {
- {/* ── DALL-E Image Generation ────────────────────────────────── */} + {/* ── Image Generation ────────────────────────────────── */}

- DALL-E Image Generation + Image Generation

- Used to generate AI cover art for projects. Leave blank to disable AI cover generation. + Used to generate AI cover art for projects. Configure at least one provider below.

-
-
-
{/* ── SMTP / Email ──────────────────────────────────────────── */} diff --git a/apps/web/src/components/admin/UsersClient.tsx b/apps/web/src/components/admin/UsersClient.tsx index e936a4d..6126605 100644 --- a/apps/web/src/components/admin/UsersClient.tsx +++ b/apps/web/src/components/admin/UsersClient.tsx @@ -3,6 +3,8 @@ import { useState, useMemo } from "react"; import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, type PermissionOverrides } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; +import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; +import { SuccessToast } from "~/components/ui/SuccessToast.js"; import { FilterChips } from "~/components/ui/FilterChips.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; @@ -90,6 +92,12 @@ export function UsersClient() { const [actionError, setActionError] = useState(null); const [search, setSearch] = useState(""); const [roleFilter, setRoleFilter] = useState(""); + const [editingName, setEditingName] = useState<{ userId: string; name: string } | null>(null); + const [passwordTarget, setPasswordTarget] = useState<{ userId: string; userName: string } | null>(null); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordError, setPasswordError] = useState(null); + const [passwordSuccess, setPasswordSuccess] = useState(false); const utils = trpc.useUtils(); @@ -166,6 +174,61 @@ export function UsersClient() { onError: (err) => setActionError(err.message), }); + const setPasswordMutation = trpc.user.setPassword.useMutation({ + onSuccess: () => { + setPasswordSuccess(true); + setNewPassword(""); + setConfirmPassword(""); + setPasswordError(null); + setTimeout(() => { + setPasswordTarget(null); + setPasswordSuccess(false); + }, 1500); + }, + onError: (err) => setPasswordError(err.message), + }); + + const updateNameMutation = trpc.user.updateName.useMutation({ + onSuccess: async () => { + await utils.user.list.invalidate(); + setEditingName(null); + }, + onError: (err) => setActionError(err.message), + }); + + function openSetPassword(user: UserRow) { + setPasswordTarget({ userId: user.id, userName: user.name ?? user.email }); + setNewPassword(""); + setConfirmPassword(""); + setPasswordError(null); + setPasswordSuccess(false); + } + + function closeSetPassword() { + setPasswordTarget(null); + setNewPassword(""); + setConfirmPassword(""); + setPasswordError(null); + setPasswordSuccess(false); + } + + async function handleSetPassword() { + if (!passwordTarget) return; + if (newPassword.length < 8) { + setPasswordError("Password must be at least 8 characters"); + return; + } + if (newPassword !== confirmPassword) { + setPasswordError("Passwords do not match"); + return; + } + setPasswordError(null); + await setPasswordMutation.mutateAsync({ + userId: passwordTarget.userId, + password: newPassword, + }); + } + function openEdit(user: UserRow) { const role = (user.systemRole as SystemRole) ?? SystemRole.USER; const overrides = user.permissionOverrides as PermissionOverrides | null; @@ -291,7 +354,8 @@ export function UsersClient() { updateRoleMutation.isPending || setPermissionsMutation.isPending || resetPermissionsMutation.isPending || - createUserMutation.isPending; + createUserMutation.isPending || + setPasswordMutation.isPending; function clearAll() { setSearch(""); @@ -474,13 +538,26 @@ export function UsersClient() { {new Date(user.createdAt).toLocaleDateString("en-GB")} - +
+ + +
))} @@ -488,6 +565,81 @@ export function UsersClient() {
+ {/* Set Password Modal */} + +
+

+ Set Password for {passwordTarget?.userName} +

+
+ +
+ {passwordError && ( +
+ {passwordError} +
+ )} + +
+ + setNewPassword(e.target.value)} + placeholder="Min. 8 characters" + className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + autoComplete="new-password" + /> + {newPassword.length > 0 && newPassword.length < 8 && ( +

+ {8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""} needed +

+ )} +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Repeat password" + className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + autoComplete="new-password" + /> + {confirmPassword.length > 0 && newPassword !== confirmPassword && ( +

+ Passwords do not match +

+ )} +
+
+ +
+ + +
+
+ + + {/* Create User Modal */} {createState && (
@@ -613,6 +765,61 @@ export function UsersClient() { {/* Modal Body */}
+ {/* User Name */} +
+

+ Display Name +

+ {editingName?.userId === editState.userId ? ( +
+ setEditingName({ ...editingName, name: e.target.value })} + className="flex-1 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter" && editingName.name.trim()) { + updateNameMutation.mutate({ id: editingName.userId, name: editingName.name.trim() }); + } + if (e.key === "Escape") setEditingName(null); + }} + /> + + +
+ ) : ( +
+ + {(users as any)?.find((u: any) => u.id === editState.userId)?.name ?? "—"} + + +
+ )} +
+ {/* System Role */}

diff --git a/apps/web/src/components/admin/UtilizationCategoriesClient.tsx b/apps/web/src/components/admin/UtilizationCategoriesClient.tsx index 6e5eced..1c46382 100644 --- a/apps/web/src/components/admin/UtilizationCategoriesClient.tsx +++ b/apps/web/src/components/admin/UtilizationCategoriesClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -159,9 +160,8 @@ export function UtilizationCategoriesClient() {

{/* Create/Edit Modal */} - {editing && ( -
-
+ setEditing(null)} maxWidth="max-w-md"> + {editing && (<>

{editing.id ? "Edit Category" : "Add Category"} @@ -236,9 +236,8 @@ export function UtilizationCategoriesClient() { {isPending ? "Saving..." : editing.id ? "Update" : "Create"}

-
-
- )} + )} +
); } diff --git a/apps/web/src/components/admin/WebhooksClient.tsx b/apps/web/src/components/admin/WebhooksClient.tsx index cb19158..48c2f01 100644 --- a/apps/web/src/components/admin/WebhooksClient.tsx +++ b/apps/web/src/components/admin/WebhooksClient.tsx @@ -176,7 +176,7 @@ export function WebhooksClient() {

Webhooks

- Configure outbound webhooks to notify external services about events in Planarchy. + Configure outbound webhooks to notify external services about events in CapaKraken.

)} + {/* Overlap warning */} + {overlapWarning && ( +
+ {"\u26A0"} {overlapWarning} +
+ )} + {/* Footer */}
+ ))} +
+ + {/* Tab content */} + {activeTab === "overview" && data && ( + + )} + {activeTab === "search" && } + {activeTab === "gaps" && } + {activeTab === "people" && data && ( + e.skill)} + allChapters={data.allChapters} + /> + )} +
+ ); +} diff --git a/apps/web/src/components/analytics/skills/GapsTab.tsx b/apps/web/src/components/analytics/skills/GapsTab.tsx new file mode 100644 index 0000000..6d21704 --- /dev/null +++ b/apps/web/src/components/analytics/skills/GapsTab.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useMemo } from "react"; +import { trpc } from "~/lib/trpc/client.js"; +import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; +import { useTableSort } from "~/hooks/useTableSort.js"; +import { GapIndicator } from "./shared.js"; + +export function GapsTab() { + const { data, isLoading } = trpc.resource.getSkillMarketplace.useQuery( + { searchSkill: undefined, minProficiency: 1, availableOnly: false }, + { staleTime: 60_000 }, + ); + + const gapData = useMemo(() => data?.gapData ?? [], [data?.gapData]); + const { sorted, sortField, sortDir, toggle } = useTableSort(gapData); + + if (isLoading) { + return ( +
+
+
+
+ ); + } + + return ( +
+
+
+

Supply vs Demand

+

+ Supply = resources with proficiency 3+ · Demand = unfilled demand requirements · Sorted by largest gap +

+
+ + {sorted.length === 0 ? ( +

+ No gap data available. Gaps appear when projects have unfilled demand requirements with required skills. +

+ ) : ( +
+ + + + + + + + + + + + {sorted.map((row) => { + const maxBar = Math.max(row.supply, row.demand, 1); + return ( + + + + + + + + ); + })} + +
Visual
{row.skill}{row.supply}{row.demand} +
+
0 ? 4 : 0 }} + title={`Supply: ${row.supply}`} + /> +
0 ? 4 : 0 }} + title={`Demand: ${row.demand}`} + /> +
+
+
+
+ Supply (prof. 3+) +
+
+ Demand (unfilled) +
+
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/analytics/skills/OverviewTab.tsx b/apps/web/src/components/analytics/skills/OverviewTab.tsx new file mode 100644 index 0000000..5109054 --- /dev/null +++ b/apps/web/src/components/analytics/skills/OverviewTab.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState } from "react"; +import dynamic from "next/dynamic"; +import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; +import { useTableSort } from "~/hooks/useTableSort.js"; +import { ProficiencyBadge } from "./shared.js"; + +const SkillDistributionChart = dynamic( + () => import("~/components/analytics/SkillDistributionChart.js"), + { ssr: false, loading: () =>
}, +); + +interface AggregatedSkill { + skill: string; + category: string; + count: number; + avgProficiency: number; + chapters: string[]; +} + +interface OverviewTabProps { + aggregated: AggregatedSkill[]; + categories: string[]; + totalResources: number; + totalSkillEntries: number; +} + +export function OverviewTab({ aggregated, categories, totalResources, totalSkillEntries }: OverviewTabProps) { + const [categoryFilter, setCategoryFilter] = useState(""); + const [minCount, setMinCount] = useState(1); + + const filtered = aggregated.filter((e) => { + if (categoryFilter && e.category !== categoryFilter) return false; + if (e.count < minCount) return false; + return true; + }); + + const { sorted, sortField, sortDir, toggle } = useTableSort(filtered); + const top10 = filtered.slice(0, 10); + const avgProf = aggregated.length > 0 + ? Math.round(aggregated.reduce((s, e) => s + e.avgProficiency, 0) / aggregated.length * 10) / 10 + : 0; + const gapCount = aggregated.filter((e) => e.count < 3 && e.avgProficiency >= 3).length; + + async function exportXlsx() { + const XLSX = await import("xlsx"); + const rows = sorted.map((e) => ({ + Skill: e.skill, + Category: e.category, + "# Resources": e.count, + "Avg Proficiency": e.avgProficiency, + Chapters: e.chapters.join(", "), + })); + const ws = XLSX.utils.json_to_sheet(rows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Skills Overview"); + XLSX.writeFile(wb, `skills-overview-${Date.now()}.xlsx`); + } + + return ( +
+ {/* KPI Cards */} +
+ {[ + { label: "Total Resources", value: totalResources, color: "text-brand-600 dark:text-brand-400" }, + { label: "Distinct Skills", value: totalSkillEntries, color: "text-indigo-600 dark:text-indigo-400" }, + { label: "Avg Proficiency", value: avgProf, color: "text-amber-600 dark:text-amber-400" }, + { label: "Scarce Skills", value: gapCount, color: "text-red-600 dark:text-red-400" }, + ].map((kpi) => ( +
+

{kpi.label}

+

{kpi.value}

+
+ ))} +
+ + {/* Filters + Export */} +
+ + + + + {filtered.length} skills shown + + +
+ + {/* Distribution Chart */} + {top10.length > 0 && ( +
+

Top 10 Skills by Resource Count

+ +

Bar color = average proficiency (light to dark = low to high)

+
+ )} + + {/* Skills Table */} +
+ + + + + + + + + + + + {sorted.map((e) => ( + + + + + + + + ))} + {sorted.length === 0 && ( + + + + )} + +
Chapters
{e.skill}{e.category}{e.count}{e.chapters.join(", ") || "---"}
+ No skills found matching the filters. +
+
+
+ ); +} diff --git a/apps/web/src/components/analytics/skills/PeopleFinderTab.tsx b/apps/web/src/components/analytics/skills/PeopleFinderTab.tsx new file mode 100644 index 0000000..99e1bc3 --- /dev/null +++ b/apps/web/src/components/analytics/skills/PeopleFinderTab.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useState, useId } from "react"; +import Link from "next/link"; +import { trpc } from "~/lib/trpc/client.js"; +import { ProficiencyBadge, PROFICIENCY_LABELS, proficiencyClasses } from "./shared.js"; + +type SkillRule = { skill: string; minProficiency: number }; + +interface PeopleFinderTabProps { + allSkillNames: string[]; + allChapters: string[]; +} + +export function PeopleFinderTab({ allSkillNames, allChapters }: PeopleFinderTabProps) { + const datalistId = useId(); + const [rules, setRules] = useState([]); + const [operator, setOperator] = useState<"AND" | "OR">("AND"); + const [chapter, setChapter] = useState(""); + + const activeRules = rules.filter((r) => r.skill.trim().length > 0); + const { data: results, isFetching } = trpc.resource.searchBySkills.useQuery( + { rules: activeRules, operator, ...(chapter ? { chapter } : {}) }, + { enabled: activeRules.length > 0, staleTime: 30_000 }, + ); + + function addRule() { setRules((prev) => [...prev, { skill: "", minProficiency: 1 }]); } + function removeRule(idx: number) { setRules((prev) => prev.filter((_, i) => i !== idx)); } + function updateRule(idx: number, patch: Partial) { + setRules((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r))); + } + + async function exportXlsx() { + if (!results || results.length === 0) return; + const XLSX = await import("xlsx"); + const rows = results.map((p) => ({ + Name: p.displayName, + EID: p.eid ?? "", + Chapter: p.chapter ?? "", + "Matched Skills": p.matchedSkills.map((s) => `${s.skill} (${s.proficiency})`).join(", "), + })); + const ws = XLSX.utils.json_to_sheet(rows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "People Finder"); + XLSX.writeFile(wb, `people-finder-${Date.now()}.xlsx`); + } + + return ( +
+
+
+

People Finder

+ Find resources that match skill criteria +
+ + {/* Datalist */} + + {allSkillNames.map((s) => + + {/* Rules */} +
+ {rules.map((rule, idx) => ( +
+ {idx > 0 ? ( + + ) : ( + knows + )} + + updateRule(idx, { skill: e.target.value })} + className="flex-1 min-w-40 px-3 py-1.5 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-brand-500" + /> + +
+ min. +
+ {[1, 2, 3, 4, 5].map((lvl) => ( + + ))} +
+
+ + +
+ ))} +
+ + {/* Controls row */} +
+ + + {rules.length > 1 && ( +
+ Match: + {(["AND", "OR"] as const).map((op) => ( + + ))} +
+ )} + + {allChapters.length > 0 && ( +
+ Chapter: + +
+ )} + + {results && results.length > 0 && ( + + )} +
+ + {/* Results */} + {activeRules.length > 0 && ( +
+ {isFetching ? ( +
Searching...
+ ) : results && results.length === 0 ? ( +

No resources match these criteria.

+ ) : results && results.length > 0 ? ( + <> +

+ {results.length} resource{results.length !== 1 ? "s" : ""} found +

+
+ {results.map((person) => ( +
+
+
+ + {person.displayName} + + {person.eid && ( + {person.eid} + )} + {person.chapter && ( + {person.chapter} + )} +
+
+ {person.matchedSkills.map((s) => ( + + {s.skill} {s.proficiency} + + ))} +
+
+ + View + +
+ ))} +
+ + ) : null} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/analytics/skills/SearchTab.tsx b/apps/web/src/components/analytics/skills/SearchTab.tsx new file mode 100644 index 0000000..9a22ad8 --- /dev/null +++ b/apps/web/src/components/analytics/skills/SearchTab.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { trpc } from "~/lib/trpc/client.js"; +import { useDebounce } from "~/hooks/useDebounce.js"; +import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; +import { useTableSort } from "~/hooks/useTableSort.js"; +import { ProficiencyBadge, PROFICIENCY_LABELS, formatDate } from "./shared.js"; + +export function SearchTab() { + const [searchSkill, setSearchSkill] = useState(""); + const [minProficiency, setMinProficiency] = useState(1); + const [availableOnly, setAvailableOnly] = useState(false); + + const debouncedSearch = useDebounce(searchSkill, 300); + + const { data, isLoading } = trpc.resource.getSkillMarketplace.useQuery( + { searchSkill: debouncedSearch || undefined, minProficiency, availableOnly }, + { staleTime: 30_000, enabled: debouncedSearch.trim().length > 0 }, + ); + + const { sorted, sortField, sortDir, toggle } = useTableSort(data?.searchResults ?? []); + + return ( +
+ {/* Filters */} +
+
+ {/* Search input */} +
+ + + + setSearchSkill(e.target.value)} + className="pl-8 pr-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-brand-500 w-60" + /> +
+ + {/* Min proficiency */} +
+ Min. proficiency: +
+ {[1, 2, 3, 4, 5].map((lvl) => ( + + ))} +
+
+ + {/* Available only */} + +
+ + {/* Results */} + {debouncedSearch.trim().length > 0 && ( +
+ {isLoading ? ( +
Searching...
+ ) : sorted.length === 0 ? ( +

+ No resources found with "{debouncedSearch}" at proficiency {minProficiency}+. +

+ ) : ( + <> +

+ {sorted.length} resource{sorted.length !== 1 ? "s" : ""} found +

+
+ + + + + + + + + + + + + {sorted.map((r) => ( + + + + + + + + + ))} + +
+ + {r.displayName} + + {r.chapter ?? "---"}{r.skillName} + = 90 ? "text-red-600 dark:text-red-400" + : r.utilizationPercent >= 70 ? "text-amber-600 dark:text-amber-400" + : "text-green-600 dark:text-green-400" + }`}> + {r.utilizationPercent}% + + {formatDate(r.availableFrom)}
+
+ + )} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/analytics/skills/shared.tsx b/apps/web/src/components/analytics/skills/shared.tsx new file mode 100644 index 0000000..2f826e9 --- /dev/null +++ b/apps/web/src/components/analytics/skills/shared.tsx @@ -0,0 +1,50 @@ +export const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"]; + +export const PROFICIENCY_CLASSES = [ + "bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500", + "bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/60 dark:text-blue-200 dark:border-blue-600", + "bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-900/60 dark:text-indigo-200 dark:border-indigo-500", + "bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/60 dark:text-amber-200 dark:border-amber-500", + "bg-green-100 text-green-800 border-green-300 dark:bg-green-900/60 dark:text-green-200 dark:border-green-500", +]; + +export function proficiencyClasses(level: number): string { + const idx = Math.max(0, Math.min(4, Math.round(level) - 1)); + return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!; +} + +export function ProficiencyBadge({ value }: { value: number }) { + return ( + + {value} {PROFICIENCY_LABELS[value] ?? ""} + + ); +} + +export function GapIndicator({ gap }: { gap: number }) { + if (gap > 0) { + return ( + + -{gap} shortage + + ); + } + if (gap < 0) { + return ( + + +{Math.abs(gap)} surplus + + ); + } + return ( + + balanced + + ); +} + +export function formatDate(iso: string | null): string { + if (!iso) return "Not within 30d"; + const d = new Date(iso); + return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); +} diff --git a/apps/web/src/components/blueprints/BlueprintFieldCatalog.tsx b/apps/web/src/components/blueprints/BlueprintFieldCatalog.tsx new file mode 100644 index 0000000..6745ea0 --- /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 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500"; + +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 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 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..073608a 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"; @@ -369,7 +369,7 @@ export function BlueprintsClient() { const isProject = bp.target === "PROJECT"; return ( - +
-
{bp.name}
+
{bp.name}
{bp.description &&
{bp.description}
}
- + {bp.target} - {fieldCount} - {isProject ? presetCount : "—"} + {fieldCount} + {isProject ? presetCount : "—"} {bp.isGlobal ? ( - + Global ) : ( @@ -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 ( +