Merge pull request 'feat: Dispo V2 Import, Blueprint Refactor, Activity History, Shoring Ratio + 40 more features' (#17) from feature/dispo-v2-import into main

This commit is contained in:
2026-03-27 12:36:47 +01:00
141 changed files with 11581 additions and 1744 deletions
+18 -8
View File
@@ -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;
+3
View File
@@ -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:*",
+2 -2
View File
@@ -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",
@@ -0,0 +1,5 @@
import { ActivityLogClient } from "~/components/admin/ActivityLogClient.js";
export default function ActivityLogPage() {
return <ActivityLogClient />;
}
@@ -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 <DispoImportDetailClient batchId={batchId} />;
}
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function DispoImportRedirect() {
redirect("/admin/imports?tab=dispo");
}
@@ -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: () => <div className="p-6"><div className="h-8 w-48 shimmer-skeleton rounded" /><div className="mt-4 h-64 shimmer-skeleton rounded-xl" /></div> },
);
const BatchSkillImport = dynamic(
() => import("~/components/admin/BatchSkillImport.js").then((m) => m.BatchSkillImport),
{ loading: () => <div className="p-6"><div className="h-8 w-48 shimmer-skeleton rounded" /><div className="mt-4 h-64 shimmer-skeleton rounded-xl" /></div> },
);
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<Tab>(initialTab);
return (
<div className="app-page flex h-full flex-col gap-5 pb-6">
<div className="app-page-header">
<div>
<h1 className="app-page-title">Data Import</h1>
<p className="app-page-subtitle mt-1">Import planning data and skill matrices</p>
</div>
</div>
{/* Tab bar */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
{TABS.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={`px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === tab.key
? "border-brand-500 text-brand-600 dark:text-brand-400"
: "border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab content */}
<div className="flex-1 min-h-0">
{activeTab === "dispo" && <DispoImportClient />}
{activeTab === "skills" && <BatchSkillImport />}
</div>
</div>
);
}
@@ -1,5 +1,5 @@
import { BatchSkillImport } from "~/components/admin/BatchSkillImport.js";
import { redirect } from "next/navigation";
export default function BatchSkillImportPage() {
return <BatchSkillImport />;
export default function SkillImportRedirect() {
redirect("/admin/imports?tab=skills");
}
@@ -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 (
@@ -1,5 +1,5 @@
import { SkillMarketplace } from "~/components/analytics/SkillMarketplace.js";
import { redirect } from "next/navigation";
export default function SkillMarketplacePage() {
return <SkillMarketplace />;
redirect("/analytics/skills?tab=search");
}
@@ -1,5 +1,5 @@
import { SkillsAnalytics } from "~/components/analytics/SkillsAnalytics.js";
import { SkillsHub } from "~/components/analytics/SkillsHub.js";
export default function SkillsAnalyticsPage() {
return <SkillsAnalytics />;
export default function SkillsHubPage() {
return <SkillsHub />;
}
@@ -466,7 +466,7 @@ export function EstimatesClient() {
No estimates yet
</p>
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
Start with the wizard to create a connected estimate from plANARCHY data.
Start with the wizard to create a connected estimate from CapaKraken data.
</p>
</div>
) : (
@@ -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() {
)}
</td>
);
case "shoring":
return (
<td key={col.key} className="px-4 py-3 text-center">
<ShoringBadge projectId={project.id} />
</td>
);
case "responsible":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"></td>;
default:
@@ -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) */}
<BudgetStatusCard projectId={project.id} />
{/* Nearshore ratio indicator (client component) */}
<ShoringIndicator projectId={project.id} />
{/* Assignments table (client component with delete action) */}
<ProjectAssignmentsTable assignments={project.assignments as never} />
@@ -50,8 +50,8 @@ export default async function ScenarioPage({ params }: ScenarioPageProps) {
<ScenarioPlanner
projectId={id}
baseline={baseline}
resources={resources as never}
roles={roles as never}
resources={((resources as any)?.resources ?? resources) as never}
roles={(Array.isArray(roles) ? roles : []) as never}
/>
</div>
);
@@ -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":
@@ -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" };
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ const TimelineView = dynamic(
export default function TimelinePage() {
return (
<div className="app-page flex max-h-[100dvh] flex-col gap-5 overflow-hidden pb-6">
<div className="app-page flex max-h-[100dvh] flex-col gap-5 pb-6">
<div className="app-page-header">
<div>
<h1 className="app-page-title">Timeline</h1>
+1 -1
View File
@@ -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 <MyVacationsClient />;
+2 -2
View File
@@ -37,7 +37,7 @@ export default function SignInPage() {
<div className="hidden rounded-[2rem] border border-white/70 bg-white/75 p-10 shadow-2xl backdrop-blur lg:flex lg:flex-col lg:justify-between dark:border-slate-800 dark:bg-slate-950/60">
<div>
<span className="inline-flex rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-900/50 dark:bg-brand-900/20 dark:text-brand-300">
plANARCHY Control Center
CapaKraken Control Center
</span>
<h1 className="mt-6 font-display text-5xl font-semibold leading-tight text-gray-900 dark:text-gray-50">
Resource planning that stays readable under pressure.
@@ -66,7 +66,7 @@ export default function SignInPage() {
<div className="app-surface-strong p-8">
<div className="mb-8">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-600">Welcome Back</p>
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">Sign in to plANARCHY</h2>
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">Sign in to CapaKraken</h2>
<p className="mt-2 text-sm text-gray-500">Resource Planning, staffing, and forecasting.</p>
</div>
+5 -5
View File
@@ -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"],
},
@@ -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<string, { label: string; className: string }> = {
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, (id: string) => 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<string, unknown>;
after?: Record<string, unknown>;
diff?: Record<string, DiffEntry>;
metadata?: Record<string, unknown>;
};
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 (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${badge.className}`}>
{badge.label}
</span>
);
}
function DiffView({ changes }: { changes: Changes }) {
const diff = changes.diff;
if (!diff || Object.keys(diff).length === 0) {
return <p className="text-sm text-gray-500 dark:text-gray-400">No field-level diff available.</p>;
}
return (
<div className="space-y-1">
{Object.entries(diff).map(([field, { old: oldVal, new: newVal }]) => (
<div key={field} className="flex items-start gap-2 text-sm">
<span className="min-w-[120px] shrink-0 font-medium text-gray-700 dark:text-gray-300">{field}</span>
<span className="rounded bg-red-50 px-1.5 py-0.5 text-red-700 line-through dark:bg-red-900/20 dark:text-red-400">
{formatValue(oldVal)}
</span>
<span className="text-gray-400">&rarr;</span>
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400">
{formatValue(newVal)}
</span>
</div>
))}
</div>
);
}
function ExpandedDiff({ entryId }: { entryId: string }) {
const { data, isLoading } = trpc.auditLog.getById.useQuery(
{ id: entryId },
{ staleTime: 300_000 },
);
if (isLoading) {
return (
<div className="border-t border-gray-100 px-4 py-3 dark:border-slate-700">
<div className="h-4 w-48 shimmer-skeleton rounded" />
</div>
);
}
const changes = parseChanges((data as any)?.changes);
return (
<div className="border-t border-gray-100 px-4 py-3 dark:border-slate-700">
<DiffView changes={changes} />
</div>
);
}
function SummaryCards({ summary }: { summary: { byEntityType: Record<string, number>; total: number } }) {
const sorted = useMemo(() => {
return Object.entries(summary.byEntityType)
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
}, [summary.byEntityType]);
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-800">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Total (7d)</p>
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">{summary.total}</p>
</div>
{sorted.map(([type, count]) => (
<div key={type} className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-800">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">{type}</p>
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">{count}</p>
</div>
))}
</div>
);
}
// ─── 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<string | null>(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<string, unknown> = { 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<string | undefined>(undefined);
const [allEntries, setAllEntries] = useState<any[]>([]);
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 (
<div className="mx-auto max-w-7xl space-y-6 p-4 sm:p-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Activity Log</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{totalCount.toLocaleString()} changes recorded in the last 7 days
</p>
</div>
{/* Summary Cards */}
{summary && <SummaryCards summary={summary} />}
{/* Filter Bar */}
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-800">
<div className="min-w-[140px]">
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">Entity Type</label>
<select
value={entityType}
onChange={(e) => setEntityType(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"
>
<option value="">All</option>
{ENTITY_TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div className="min-w-[120px]">
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">Action</label>
<select
value={action}
onChange={(e) => setAction(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"
>
<option value="">All</option>
{ACTION_OPTIONS.map((a) => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
<div className="min-w-[160px]">
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">User</label>
<select
value={userId}
onChange={(e) => setUserId(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"
>
<option value="">All</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.name ?? u.email}</option>
))}
</select>
</div>
<div className="min-w-[130px]">
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">From</label>
<input
type="date"
value={startDate}
onChange={(e) => 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"
/>
</div>
<div className="min-w-[130px]">
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">To</label>
<input
type="date"
value={endDate}
onChange={(e) => 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"
/>
</div>
<div className="min-w-[200px] flex-1">
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">Search</label>
<input
type="text"
placeholder="Search entity name or summary..."
value={search}
onChange={(e) => 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"
/>
</div>
<button
onClick={() => {
setEntityType("");
setAction("");
setUserId("");
setSearch("");
setStartDate("");
setEndDate("");
}}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 dark:border-slate-600 dark:text-gray-400 dark:hover:bg-slate-700"
>
Reset
</button>
</div>
{/* Timeline List */}
<div className="space-y-2">
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
</div>
)}
{!isLoading && allEntries.length === 0 && (
<div className="rounded-xl border border-dashed border-gray-300 bg-white py-16 text-center dark:border-slate-600 dark:bg-slate-800">
<svg className="mx-auto h-10 w-10 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="mt-3 text-sm font-medium text-gray-600 dark:text-gray-400">No activity found</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-500">Try adjusting your filters or date range.</p>
</div>
)}
{allEntries.map((entry) => {
const isExpanded = expandedId === entry.id;
const entityLink = ENTITY_LINKS[entry.entityType]?.(entry.entityId);
return (
<div
key={entry.id}
className="rounded-xl border border-gray-200 bg-white shadow-sm transition-shadow hover:shadow-md dark:border-slate-700 dark:bg-slate-800"
>
<button
onClick={() => toggleExpand(entry.id)}
className="flex w-full items-start gap-3 p-4 text-left"
>
{/* User Avatar */}
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-200 text-xs font-semibold text-gray-700 dark:bg-slate-600 dark:text-gray-200">
{entry.user ? userInitials(entry.user.name, entry.user.email) : "SY"}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{entry.user?.name ?? entry.user?.email ?? "System"}
</span>
<ActionBadge action={entry.action} />
<span className="text-xs text-gray-500 dark:text-gray-400">
{entry.entityType}
</span>
{entry.entityName && (
<>
<span className="text-gray-400">&middot;</span>
{entityLink ? (
<Link
href={entityLink as Route}
onClick={(e) => e.stopPropagation()}
className="text-sm font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{entry.entityName}
</Link>
) : (
<span className="text-sm text-gray-700 dark:text-gray-300">{entry.entityName}</span>
)}
</>
)}
</div>
{entry.summary && (
<p className="mt-0.5 text-sm text-gray-600 dark:text-gray-400">{entry.summary}</p>
)}
</div>
{/* Timestamp */}
<div className="shrink-0 text-right">
<span
className="text-xs text-gray-500 dark:text-gray-400"
title={new Date(entry.createdAt).toLocaleString("de-DE")}
>
{relativeTime(entry.createdAt)}
</span>
{entry.source && (
<p className="mt-0.5 text-[10px] uppercase tracking-wide text-gray-400 dark:text-gray-500">
{entry.source}
</p>
)}
</div>
{/* Expand indicator */}
<svg
className={`h-4 w-4 shrink-0 text-gray-400 transition-transform ${isExpanded ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Expanded Diff — fetched on demand */}
{isExpanded && <ExpandedDiff entryId={entry.id} />}
</div>
);
})}
{/* Load More */}
{hasNextPage && (
<div className="flex justify-center pt-4">
<button
onClick={loadMore}
disabled={isFetching}
className="rounded-lg border border-gray-300 bg-white px-6 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-gray-300 dark:hover:bg-slate-700"
>
{isFetching ? "Loading..." : "Load more"}
</button>
</div>
)}
</div>
</div>
);
}
@@ -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<EditingRule | null>(null);
const [confirmDeleteRule, setConfirmDeleteRule] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
@@ -219,7 +222,7 @@ export function CalculationRulesClient() {
<td className="px-4 py-3 text-right text-sm">
<button onClick={() => openEdit(rule)} className="mr-2 text-blue-600 hover:underline dark:text-blue-400">Edit</button>
<button
onClick={() => { if (confirm("Delete this rule?")) deleteMut.mutate({ id: rule.id }); }}
onClick={() => setConfirmDeleteRule(rule.id)}
className="text-red-600 hover:underline dark:text-red-400"
>
Delete
@@ -240,9 +243,9 @@ export function CalculationRulesClient() {
</div>
{/* ── Edit/Create Modal ── */}
{editing && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-lg">
{editing && (<>
<div className="p-6">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Rule" : "New Rule"}
</h2>
@@ -363,8 +366,22 @@ export function CalculationRulesClient() {
{createMut.isPending || updateMut.isPending ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
</div>
</>)}
</AnimatedModal>
{confirmDeleteRule && (
<ConfirmDialog
title="Delete rule"
message="Are you sure you want to delete this calculation rule?"
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteMut.mutate({ id: confirmDeleteRule });
setConfirmDeleteRule(null);
}}
onCancel={() => setConfirmDeleteRule(null)}
/>
)}
</div>
);
File diff suppressed because it is too large Load Diff
@@ -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<EditingCountry | null>(null);
const [cityName, setCityName] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [confirmDeleteCity, setConfirmDeleteCity] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
@@ -236,11 +239,7 @@ export function CountriesClient() {
{city.name}
<button
type="button"
onClick={() => {
if (confirm(`Delete metro city "${city.name}"?`)) {
deleteCityMut.mutate({ id: city.id });
}
}}
onClick={() => setConfirmDeleteCity(city.id)}
className="text-gray-400 hover:text-red-500 text-xs leading-none ml-1"
>
&times;
@@ -274,9 +273,8 @@ export function CountriesClient() {
})()}
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-lg mx-4 flex flex-col max-h-[90vh]">
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-lg" className="flex flex-col max-h-[90vh]">
{editing && (<>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Country" : "Add Country"}
@@ -406,8 +404,21 @@ export function CountriesClient() {
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
</>)}
</AnimatedModal>
{confirmDeleteCity && (
<ConfirmDialog
title="Delete metro city"
message="Are you sure you want to delete this metro city?"
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteCityMut.mutate({ id: confirmDeleteCity });
setConfirmDeleteCity(null);
}}
onCancel={() => setConfirmDeleteCity(null)}
/>
)}
</div>
);
@@ -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<BatchStatus, { label: string; variant: "default" | "success" | "warning" | "danger" | "info" }> = {
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 <Badge variant={cfg.variant}>{cfg.label}</Badge>;
}
/* ------------------------------------------------------------------ */
/* 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<Record<string, string>>({});
const [error, setError] = useState<string | null>(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<string, string>).referenceWorkbookPath ?? "",
planningWorkbookPath: (nonEmpty as Record<string, string>).planningWorkbookPath ?? "",
chargeabilityWorkbookPath: (nonEmpty as Record<string, string>).chargeabilityWorkbookPath ?? "",
...(nonEmpty.rosterWorkbookPath ? { rosterWorkbookPath: nonEmpty.rosterWorkbookPath } : {}),
...(nonEmpty.costWorkbookPath ? { costWorkbookPath: nonEmpty.costWorkbookPath } : {}),
} as any);
}
return (
<AnimatedModal open={open} onClose={onClose} maxWidth="max-w-lg">
<div className="px-6 py-5">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
New Dispo Import
</h2>
<div className="space-y-3">
{WORKBOOK_LABELS.map(({ key, label, placeholder }) => (
<div key={key}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{label}
</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder={placeholder}
value={filePaths[key] ?? ""}
onChange={(e) =>
setFilePaths((prev) => ({ ...prev, [key]: e.target.value }))
}
/>
</div>
))}
</div>
{error && (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<div className="flex justify-end gap-3 mt-6">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={stageMutation.isPending}
>
{stageMutation.isPending ? "Staging..." : "Stage Import"}
</Button>
</div>
</div>
</AnimatedModal>
);
}
/* ------------------------------------------------------------------ */
/* Main Component */
/* ------------------------------------------------------------------ */
export function DispoImportClient() {
const [statusFilter, setStatusFilter] = useState<BatchStatus | "">("");
const [showNewModal, setShowNewModal] = useState(false);
const utils = trpc.useUtils();
const { data: batches, isLoading } = trpc.dispo.listImportBatches.useQuery(
{ status: statusFilter || undefined },
{ staleTime: 10_000 },
);
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Dispo Import
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage Dispo imports
</p>
</div>
<Button onClick={() => setShowNewModal(true)}>New Import</Button>
</div>
{/* Status filter */}
<div className="mb-4 flex items-center gap-3">
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">
Status:
</label>
<select
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as BatchStatus | "")}
>
<option value="">All</option>
{Object.keys(STATUS_BADGE).map((s) => (
<option key={s} value={s}>
{STATUS_BADGE[s as BatchStatus].label}
</option>
))}
</select>
</div>
{/* Table */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<ShimmerSkeleton key={i} height={48} rounded="lg" />
))}
</div>
) : !batches?.items || batches.items.length === 0 ? (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center">
<p className="text-gray-500 dark:text-gray-400">
No import batches found.
</p>
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800/60">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
ID
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Source Files
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Staged
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Created
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Updated
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700/50">
{batches.items.map((batch: any) => (
<tr
key={batch.id}
className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors"
>
<td className="px-4 py-3">
<Link
href={`/admin/dispo-imports/${batch.id}`}
className="text-sm font-mono text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300 underline decoration-dotted"
>
{truncateId(batch.id)}
</Link>
</td>
<td className="px-4 py-3">
<StatusBadge status={batch.status} />
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate">
{batch.sourceFiles
? Object.keys(batch.sourceFiles).join(", ")
: "-"}
</td>
<td className="px-4 py-3 text-right">
{batch.stagedCounts ? (
<div className="flex items-center justify-end gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{batch.stagedCounts.resources} res</span>
<span className="text-gray-300 dark:text-gray-600">|</span>
<span>{batch.stagedCounts.projects} proj</span>
<span className="text-gray-300 dark:text-gray-600">|</span>
<span>{batch.stagedCounts.assignments} asgn</span>
{batch.stagedCounts.unresolved > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600">|</span>
<span className="text-amber-600 dark:text-amber-400 font-medium">
{batch.stagedCounts.unresolved} unresolved
</span>
</>
)}
</div>
) : (
<span className="text-xs text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{new Date(batch.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</td>
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{new Date(batch.updatedAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* New Import Modal */}
<NewImportModal
open={showNewModal}
onClose={() => setShowNewModal(false)}
onCreated={() => utils.dispo.listImportBatches.invalidate()}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -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<EditingRuleSet | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
function handleSave() {
if (!editing) return;
@@ -375,11 +377,7 @@ export function EffortRulesClient() {
Edit
</button>
<button
onClick={() => {
if (confirm(`Delete rule set "${rs.name}"?`)) {
deleteMutation.mutate({ id: rs.id });
}
}}
onClick={() => setConfirmDelete(rs.id)}
className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
>
Delete
@@ -416,6 +414,20 @@ export function EffortRulesClient() {
)}
</div>
))}
{confirmDelete && (
<ConfirmDialog
title="Delete rule set"
message="Are you sure you want to delete this rule set? This action cannot be undone."
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate({ id: confirmDelete });
setConfirmDelete(null);
}}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
);
}
@@ -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<EditingSet | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
function handleSave() {
if (!editing) return;
@@ -422,11 +424,7 @@ export function ExperienceMultipliersClient() {
Edit
</button>
<button
onClick={() => {
if (confirm(`Delete multiplier set "${s.name}"?`)) {
deleteMutation.mutate({ id: s.id });
}
}}
onClick={() => setConfirmDelete(s.id)}
className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
>
Delete
@@ -471,6 +469,20 @@ export function ExperienceMultipliersClient() {
)}
</div>
))}
{confirmDelete && (
<ConfirmDialog
title="Delete multiplier set"
message="Are you sure you want to delete this multiplier set? This action cannot be undone."
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate({ id: confirmDelete });
setConfirmDelete(null);
}}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
);
}
@@ -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<EditingGroup | null>(null);
const [editingLevel, setEditingLevel] = useState<EditingLevel | null>(null);
const [confirmDeleteLevel, setConfirmDeleteLevel] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
@@ -185,11 +188,7 @@ export function ManagementLevelsClient() {
</button>
<button
type="button"
onClick={() => {
if (confirm(`Delete level "${level.name}"?`)) {
deleteLevelMut.mutate({ id: level.id });
}
}}
onClick={() => setConfirmDeleteLevel(level.id)}
className="text-xs text-red-500 hover:text-red-700 font-medium"
>
Delete
@@ -207,9 +206,8 @@ export function ManagementLevelsClient() {
</div>
{/* Group Modal */}
{editingGroup && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<AnimatedModal open={editingGroup !== null} onClose={() => setEditingGroup(null)} maxWidth="max-w-md">
{editingGroup && (<>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingGroup.id ? "Edit Group" : "Add Group"}
@@ -264,14 +262,12 @@ export function ManagementLevelsClient() {
{isGroupPending ? "Saving..." : editingGroup.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</>)}
</AnimatedModal>
{/* Level Modal */}
{editingLevel && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-sm mx-4">
<AnimatedModal open={editingLevel !== null} onClose={() => setEditingLevel(null)} maxWidth="max-w-sm">
{editingLevel && (<>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingLevel.id ? "Edit Level" : "Add Level"}
@@ -316,8 +312,21 @@ export function ManagementLevelsClient() {
{isLevelPending ? "Saving..." : editingLevel.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
</>)}
</AnimatedModal>
{confirmDeleteLevel && (
<ConfirmDialog
title="Delete level"
message="Are you sure you want to delete this level?"
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteLevelMut.mutate({ id: confirmDeleteLevel });
setConfirmDeleteLevel(null);
}}
onCancel={() => setConfirmDeleteLevel(null)}
/>
)}
</div>
);
@@ -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() {
</div>
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-md">
{editing && (<>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{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"}
</button>
</div>
</div>
</div>
)}
</>)}
</AnimatedModal>
</div>
);
}
@@ -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<string | null>(null);
const [editingCard, setEditingCard] = useState<EditingCard | null>(null);
const [editingLine, setEditingLine] = useState<EditingLine | null>(null);
const [confirmDeleteLine, setConfirmDeleteLine] = useState<string | null>(null);
const [confirmDeactivate, setConfirmDeactivate] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 ? (
<button
type="button"
onClick={() => handleDeactivate(detail.id)}
onClick={() => setConfirmDeactivate(detail.id)}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-red-600 font-medium"
>
Deactivate
@@ -528,7 +529,7 @@ export function RateCardsClient() {
</button>
<button
type="button"
onClick={() => handleDeleteLine(line.id)}
onClick={() => setConfirmDeleteLine(line.id)}
className="text-xs text-red-500 hover:text-red-700 font-medium"
>
Delete
@@ -780,6 +781,34 @@ export function RateCardsClient() {
</div>
</div>
)}
{confirmDeleteLine && (
<ConfirmDialog
title="Delete rate line"
message="Are you sure you want to delete this rate line?"
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
void handleDeleteLine(confirmDeleteLine);
setConfirmDeleteLine(null);
}}
onCancel={() => setConfirmDeleteLine(null)}
/>
)}
{confirmDeactivate && (
<ConfirmDialog
title="Deactivate rate card"
message="Are you sure you want to deactivate this rate card?"
confirmLabel="Deactivate"
variant="danger"
onConfirm={() => {
void handleDeactivate(confirmDeactivate);
setConfirmDeactivate(null);
}}
onCancel={() => setConfirmDeactivate(null)}
/>
)}
</div>
);
}
@@ -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<ImageProvider>("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() {
</div>
</div>
{/* ── DALL-E Image Generation ────────────────────────────────── */}
{/* ── Image Generation ────────────────────────────────── */}
<div className={PANEL_CLASS}>
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
DALL-E Image Generation <InfoTooltip content="Configure the DALL-E model used for generating project cover art. Uses the same provider (OpenAI / Azure) as the chat model above." />
Image Generation <InfoTooltip content="Configure the image generation provider used for AI-generated project cover art." />
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
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.
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className={LABEL_CLASS}>
<span className="flex items-center">
Deployment Name <InfoTooltip content="The DALL-E model deployment name (e.g. dall-e-3). For OpenAI this is the model name, for Azure it is the deployment name." />
</span>
{/* Provider selector */}
<div>
<label className={LABEL_CLASS}>Provider</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="imageProvider"
value="dalle"
checked={imageProvider === "dalle"}
onChange={() => setImageProvider("dalle")}
className="accent-brand-600"
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">DALL-E (Azure OpenAI / OpenAI)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="imageProvider"
value="gemini"
checked={imageProvider === "gemini"}
onChange={() => setImageProvider("gemini")}
className="accent-brand-600"
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Google Gemini</span>
</label>
<input
type="text"
className={INPUT_CLASS}
value={dalleDeployment}
onChange={(e) => setDalleDeployment(e.target.value)}
placeholder="dall-e-3"
/>
</div>
</div>
{provider === "azure" && (
<>
{/* DALL-E settings (shown when DALL-E selected) */}
{imageProvider === "dalle" && (
<div className="space-y-4 rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">DALL-E Configuration</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className={LABEL_CLASS}>
<span className="flex items-center">
Endpoint <InfoTooltip content="Azure endpoint for the DALL-E deployment. Leave empty to use the same endpoint as the chat model." />
Deployment Name <InfoTooltip content="The DALL-E model deployment name (e.g. dall-e-3). For OpenAI this is the model name, for Azure it is the deployment name." />
</span>
</label>
<input
type="text"
className={INPUT_CLASS}
value={dalleEndpoint}
onChange={(e) => setDalleEndpoint(e.target.value)}
placeholder="Leave empty to use same endpoint as chat"
value={dalleDeployment}
onChange={(e) => setDalleDeployment(e.target.value)}
placeholder="dall-e-3"
/>
</div>
{provider === "azure" && (
<>
<div>
<label className={LABEL_CLASS}>
<span className="flex items-center">
Endpoint <InfoTooltip content="Azure endpoint for the DALL-E deployment. Leave empty to use the same endpoint as the chat model." />
</span>
</label>
<input
type="text"
className={INPUT_CLASS}
value={dalleEndpoint}
onChange={(e) => setDalleEndpoint(e.target.value)}
placeholder="Leave empty to use same endpoint as chat"
/>
</div>
<div>
<label className={LABEL_CLASS}>
<span className="flex items-center">
API Key{" "}
<InfoTooltip content="API key for the DALL-E endpoint. Leave empty to use the same API key as the chat model." />
<span className="ml-1 text-xs font-normal text-gray-400">(optional)</span>
</span>
</label>
<input
type="password"
className={INPUT_CLASS}
value={dalleApiKey}
onChange={(e) => setDalleApiKey(e.target.value)}
placeholder="Leave empty to use same API key as chat"
/>
</div>
</>
)}
</div>
{settings?.hasDalleApiKey && (
<p className="text-xs text-green-600 dark:text-green-400">A separate DALL-E API key is stored.</p>
)}
</div>
)}
{/* Gemini settings (shown when Gemini selected) */}
{imageProvider === "gemini" && (
<div className="space-y-4 rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Google Gemini Configuration</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className={LABEL_CLASS}>
<span className="flex items-center">
API Key{" "}
<InfoTooltip content="API key for the DALL-E endpoint. Leave empty to use the same API key as the chat model." />
<span className="ml-1 text-xs font-normal text-gray-400">(optional)</span>
API Key <InfoTooltip content="Google Gemini API key from Google AI Studio (aistudio.google.com)." />
</span>
</label>
<input
type="password"
className={INPUT_CLASS}
value={dalleApiKey}
onChange={(e) => setDalleApiKey(e.target.value)}
placeholder="Leave empty to use same API key as chat"
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
placeholder={settings?.hasGeminiApiKey ? "•••••••• (key is stored)" : "Enter Gemini API key"}
/>
{settings?.hasGeminiApiKey && !geminiApiKey && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">API key is stored.</p>
)}
</div>
</>
<div>
<label className={LABEL_CLASS}>
<span className="flex items-center">
Model <InfoTooltip content="Gemini model for image generation. The default model supports image output." />
</span>
</label>
<select
className={INPUT_CLASS}
value={geminiModel || "gemini-2.5-flash-image"}
onChange={(e) => setGeminiModel(e.target.value)}
>
<option value="gemini-2.5-flash-image">Gemini 2.5 Flash Image fast, high-volume</option>
<option value="gemini-3-pro-image-preview">Gemini 3 Pro Image Preview high-fidelity</option>
<option value="gemini-3.1-flash-image-preview">Gemini 3.1 Flash Image Preview latest</option>
</select>
</div>
</div>
</div>
)}
<div className="flex flex-wrap items-center gap-3 pt-1">
<button
type="button"
className={PRIMARY_BUTTON_CLASS}
disabled={saveImageMutation.isPending}
onClick={handleSaveImage}
>
{saveImageMutation.isPending ? "Saving..." : "Save Image Settings"}
</button>
{imageProvider === "gemini" && (
<button
type="button"
className="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 disabled:opacity-50"
disabled={testGeminiMut.isPending}
onClick={() => testGeminiMut.mutate()}
>
{testGeminiMut.isPending ? "Testing..." : "Test Gemini"}
</button>
)}
{imageSaved && (
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved</span>
)}
</div>
{geminiTestResult && (
<div className={`mt-3 rounded-lg px-3 py-2 text-sm ${
geminiTestResult.ok
? "bg-green-50 text-green-700 border border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800"
: "bg-red-50 text-red-700 border border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800"
}`}>
{geminiTestResult.ok
? `Gemini image generation works! Model: ${(geminiTestResult as any).model}`
: `Test failed: ${(geminiTestResult as any).error}`}
</div>
)}
</div>
{/* ── SMTP / Email ──────────────────────────────────────────── */}
+215 -8
View File
@@ -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<string | null>(null);
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
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<string | null>(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")}
</td>
<td className="px-4 py-3 text-right">
<button
type="button"
onClick={() => openEdit(user)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => openSetPassword(user)}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 font-medium"
title="Set password"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Password
</button>
<button
type="button"
onClick={() => openEdit(user)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
</div>
</td>
</tr>
))}
@@ -488,6 +565,81 @@ export function UsersClient() {
</table>
</div>
{/* Set Password Modal */}
<AnimatedModal open={!!passwordTarget} onClose={closeSetPassword} maxWidth="max-w-md">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Set Password for {passwordTarget?.userName}
</h2>
</div>
<div className="px-6 py-5 space-y-4">
{passwordError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-3 py-2 text-sm text-red-700 dark:text-red-400">
{passwordError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
New Password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
{8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""} needed
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirm Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
Passwords do not match
</p>
)}
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={closeSetPassword}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
Cancel
</button>
<button
type="button"
onClick={() => void handleSetPassword()}
disabled={setPasswordMutation.isPending || newPassword.length < 8 || newPassword !== confirmPassword}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{setPasswordMutation.isPending ? "Saving..." : "Set Password"}
</button>
</div>
</AnimatedModal>
<SuccessToast show={passwordSuccess} message="Password updated successfully" />
{/* Create User Modal */}
{createState && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
@@ -613,6 +765,61 @@ export function UsersClient() {
{/* Modal Body */}
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-6">
{/* User Name */}
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Display Name
</h3>
{editingName?.userId === editState.userId ? (
<div className="flex items-center gap-2">
<input
type="text"
value={editingName.name}
onChange={(e) => 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);
}}
/>
<button
type="button"
onClick={() => updateNameMutation.mutate({ id: editingName.userId, name: editingName.name.trim() })}
disabled={!editingName.name.trim() || updateNameMutation.isPending}
className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
>
{updateNameMutation.isPending ? "..." : "Save"}
</button>
<button
type="button"
onClick={() => setEditingName(null)}
className="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-sm"
>
Cancel
</button>
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-900 dark:text-gray-100">
{(users as any)?.find((u: any) => u.id === editState.userId)?.name ?? "—"}
</span>
<button
type="button"
onClick={() => {
const user = (users as any)?.find((u: any) => u.id === editState.userId);
setEditingName({ userId: editState.userId, name: user?.name ?? "" });
}}
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
>
Edit
</button>
</div>
)}
</section>
{/* System Role */}
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
@@ -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() {
</div>
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-md">
{editing && (<>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Category" : "Add Category"}
@@ -236,9 +236,8 @@ export function UtilizationCategoriesClient() {
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</>)}
</AnimatedModal>
</div>
);
}
@@ -176,7 +176,7 @@ export function WebhooksClient() {
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure outbound webhooks to notify external services about events in Planarchy.
Configure outbound webhooks to notify external services about events in CapaKraken.
</p>
</div>
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useMemo } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { AllocationStatus } from "@planarchy/shared";
@@ -73,6 +73,36 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{ staleTime: 60_000 },
);
// Fetch existing allocations for the selected resource+project to detect overlaps
const shouldCheckOverlap = !isDemandEntry && !!resourceId && !!projectId;
const { data: existingAllocations } = trpc.allocation.listView.useQuery(
{ projectId, resourceId },
{ enabled: shouldCheckOverlap, staleTime: 30_000 },
);
const overlapWarning = useMemo(() => {
if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null;
const formStart = new Date(startDate);
const formEnd = new Date(endDate);
if (isNaN(formStart.getTime()) || isNaN(formEnd.getTime())) return null;
const allocList = (existingAllocations as { allocations?: Array<{ id: string; resourceId?: string | null; startDate: string | Date; endDate: string | Date }> }).allocations ?? [];
for (const existing of allocList) {
// Skip the allocation being edited
if (isEditing && allocation && existing.id === allocation.id) continue;
// Only check assignments for this resource
if (existing.resourceId !== resourceId) continue;
const exStart = new Date(existing.startDate);
const exEnd = new Date(existing.endDate);
// Check date overlap
if (formStart <= exEnd && formEnd >= exStart) {
const fmt = (d: Date) => d.toISOString().slice(0, 10);
return `This resource is already assigned to this project from ${fmt(exStart)} to ${fmt(exEnd)}. Consider updating the existing assignment instead.`;
}
}
return null;
}, [shouldCheckOverlap, existingAllocations, startDate, endDate, isEditing, allocation, resourceId]);
const invalidatePlanningViews = useInvalidatePlanningViews();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -473,6 +503,13 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
</div>
)}
{/* Overlap warning */}
{overlapWarning && (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
{"\u26A0"} {overlapWarning}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-2">
<button
@@ -3,67 +3,17 @@
import { useState, useMemo } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { trpc } from "~/lib/trpc/client.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { PROFICIENCY_LABELS, ProficiencyBadge, GapIndicator, formatDate } from "~/components/analytics/skills/shared.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { trpc } from "~/lib/trpc/client.js";
const SkillDistributionChart = dynamic(
() => import("~/components/analytics/SkillDistributionChart.js"),
{ ssr: false, loading: () => <div className="h-80 shimmer-skeleton rounded-xl" /> },
);
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
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",
];
function proficiencyClasses(level: number): string {
const idx = Math.max(0, Math.min(4, Math.round(level) - 1));
return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!;
}
function ProficiencyBadge({ value }: { value: number }) {
return (
<span className={`inline-block px-2 py-0.5 text-xs rounded font-medium border ${proficiencyClasses(value)}`}>
{value} {PROFICIENCY_LABELS[value] ?? ""}
</span>
);
}
function GapIndicator({ gap }: { gap: number }) {
if (gap > 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-red-100 text-red-700 border border-red-200 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700">
-{gap} shortage
</span>
);
}
if (gap < 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-green-100 text-green-700 border border-green-200 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700">
+{Math.abs(gap)} surplus
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-gray-100 text-gray-500 border border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600">
balanced
</span>
);
}
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" });
}
export function SkillMarketplace() {
const [searchSkill, setSearchSkill] = useState("");
const [minProficiency, setMinProficiency] = useState(1);
@@ -2,6 +2,7 @@
import { useState, useId } from "react";
import dynamic from "next/dynamic";
import { PROFICIENCY_LABELS, proficiencyClasses, ProficiencyBadge } from "~/components/analytics/skills/shared.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -12,30 +13,6 @@ const SkillDistributionChart = dynamic(
{ ssr: false, loading: () => <div className="h-80 shimmer-skeleton rounded-xl" /> },
);
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
// Tailwind class sets per proficiency level (15), dark-mode aware
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",
];
function proficiencyClasses(level: number): string {
const idx = Math.max(0, Math.min(4, Math.round(level) - 1));
return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!;
}
function ProficiencyBadge({ value }: { value: number }) {
return (
<span className={`inline-block px-2 py-0.5 text-xs rounded font-medium border ${proficiencyClasses(value)}`}>
{value} {PROFICIENCY_LABELS[value] ?? ""}
</span>
);
}
type SkillRule = { skill: string; minProficiency: number };
export function SkillsAnalytics() {
@@ -0,0 +1,110 @@
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import { trpc } from "~/lib/trpc/client.js";
const OverviewTab = dynamic(() => import("./skills/OverviewTab.js").then((m) => ({ default: m.OverviewTab })), {
loading: () => <div className="h-64 shimmer-skeleton rounded-xl" />,
});
const SearchTab = dynamic(() => import("./skills/SearchTab.js").then((m) => ({ default: m.SearchTab })), {
loading: () => <div className="h-64 shimmer-skeleton rounded-xl" />,
});
const GapsTab = dynamic(() => import("./skills/GapsTab.js").then((m) => ({ default: m.GapsTab })), {
loading: () => <div className="h-64 shimmer-skeleton rounded-xl" />,
});
const PeopleFinderTab = dynamic(() => import("./skills/PeopleFinderTab.js").then((m) => ({ default: m.PeopleFinderTab })), {
loading: () => <div className="h-64 shimmer-skeleton rounded-xl" />,
});
const TABS = [
{ key: "overview", label: "Overview" },
{ key: "search", label: "Search" },
{ key: "gaps", label: "Gaps" },
{ key: "people", label: "People Finder" },
] as const;
type TabKey = (typeof TABS)[number]["key"];
export function SkillsHub() {
const searchParams = useSearchParams();
const router = useRouter();
const rawTab = searchParams.get("tab");
const activeTab: TabKey = TABS.some((t) => t.key === rawTab) ? (rawTab as TabKey) : "overview";
function setTab(tab: TabKey) {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", tab);
router.replace(`/analytics/skills?${params.toString()}` as `/analytics/skills`, { scroll: false });
}
const { data, isLoading, error } = trpc.resource.getSkillsAnalytics.useQuery(undefined, { staleTime: 60_000 });
if (isLoading) {
return (
<div className="p-6 space-y-4">
<div className="h-8 shimmer-skeleton rounded w-64" />
<div className="h-64 shimmer-skeleton rounded-xl" />
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4 text-sm text-red-700 dark:text-red-300">
{error.message}
</div>
</div>
);
}
return (
<div className="p-6 pb-24 space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Skills Hub</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{data?.totalResources} active resources &middot; {data?.totalSkillEntries} distinct skills
</p>
</div>
{/* Tab bar */}
<div className="flex gap-1 border-b border-gray-200 dark:border-slate-700">
{TABS.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setTab(tab.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${
activeTab === tab.key
? "border-brand-600 text-brand-600 dark:text-brand-400 dark:border-brand-400"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-slate-600"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab content */}
{activeTab === "overview" && data && (
<OverviewTab
aggregated={data.aggregated}
categories={data.categories}
totalResources={data.totalResources}
totalSkillEntries={data.totalSkillEntries}
/>
)}
{activeTab === "search" && <SearchTab />}
{activeTab === "gaps" && <GapsTab />}
{activeTab === "people" && data && (
<PeopleFinderTab
allSkillNames={data.aggregated.map((e) => e.skill)}
allChapters={data.allChapters}
/>
)}
</div>
);
}
@@ -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 (
<div className="space-y-4">
<div className="h-8 shimmer-skeleton rounded w-48" />
<div className="h-64 shimmer-skeleton rounded-xl" />
</div>
);
}
return (
<div className="space-y-4">
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-5 space-y-4">
<div>
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200">Supply vs Demand</h2>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Supply = resources with proficiency 3+ &middot; Demand = unfilled demand requirements &middot; Sorted by largest gap
</p>
</div>
{sorted.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-gray-500 italic py-4">
No gap data available. Gaps appear when projects have unfilled demand requirements with required skills.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
<tr>
<SortableColumnHeader label="Skill" field="skill" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<SortableColumnHeader label="Supply" field="supply" sortField={sortField} sortDir={sortDir} onSort={toggle} align="right" />
<SortableColumnHeader label="Demand" field="demand" sortField={sortField} sortDir={sortDir} onSort={toggle} align="right" />
<SortableColumnHeader label="Gap" field="gap" sortField={sortField} sortDir={sortDir} onSort={toggle} align="center" />
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Visual</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
{sorted.map((row) => {
const maxBar = Math.max(row.supply, row.demand, 1);
return (
<tr key={row.skill} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-gray-100">{row.skill}</td>
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.supply}</td>
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.demand}</td>
<td className="px-4 py-2.5 text-center"><GapIndicator gap={row.gap} /></td>
<td className="px-4 py-2.5 w-48">
<div className="flex items-center gap-1 h-4">
<div
className="h-3 rounded-sm bg-green-400 dark:bg-green-500 transition-all"
style={{ width: `${(row.supply / maxBar) * 100}%`, minWidth: row.supply > 0 ? 4 : 0 }}
title={`Supply: ${row.supply}`}
/>
<div
className="h-3 rounded-sm bg-red-400 dark:bg-red-500 transition-all"
style={{ width: `${(row.demand / maxBar) * 100}%`, minWidth: row.demand > 0 ? 4 : 0 }}
title={`Demand: ${row.demand}`}
/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="flex items-center gap-4 mt-3 px-4">
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className="w-3 h-3 rounded-sm bg-green-400 dark:bg-green-500 inline-block" /> Supply (prof. 3+)
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className="w-3 h-3 rounded-sm bg-red-400 dark:bg-red-500 inline-block" /> Demand (unfilled)
</div>
</div>
</div>
)}
</div>
</div>
);
}
@@ -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: () => <div className="h-80 shimmer-skeleton rounded-xl" /> },
);
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 (
<div className="space-y-6">
{/* KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ 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) => (
<div key={kpi.label} className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-4">
<p className="text-xs text-gray-500 dark:text-gray-400">{kpi.label}</p>
<p className={`text-2xl font-bold mt-1 ${kpi.color}`}>{kpi.value}</p>
</div>
))}
</div>
{/* Filters + Export */}
<div className="flex flex-wrap gap-3 items-center">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-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"
>
<option value="">All Categories</option>
{categories.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
Min. resources:
<input
type="number"
min={1}
max={50}
value={minCount}
onChange={(e) => setMinCount(Math.max(1, parseInt(e.target.value, 10) || 1))}
className="w-16 px-2 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"
/>
</label>
<span className="text-sm text-gray-400 dark:text-gray-500">{filtered.length} skills shown</span>
<button
type="button"
onClick={exportXlsx}
className="ml-auto px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-slate-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors"
>
Export XLSX
</button>
</div>
{/* Distribution Chart */}
{top10.length > 0 && (
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-4">Top 10 Skills by Resource Count</h2>
<SkillDistributionChart data={top10} />
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">Bar color = average proficiency (light to dark = low to high)</p>
</div>
)}
{/* Skills Table */}
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
<tr>
<SortableColumnHeader label="Skill" field="skill" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<SortableColumnHeader label="Category" field="category" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<SortableColumnHeader label="Resources" field="count" sortField={sortField} sortDir={sortDir} onSort={toggle} align="right" />
<SortableColumnHeader label="Avg Prof." field="avgProficiency" sortField={sortField} sortDir={sortDir} onSort={toggle} align="right" />
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chapters</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
{sorted.map((e) => (
<tr key={e.skill} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-gray-100">{e.skill}</td>
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400">{e.category}</td>
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{e.count}</td>
<td className="px-4 py-2.5 text-right"><ProficiencyBadge value={e.avgProficiency} /></td>
<td className="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs">{e.chapters.join(", ") || "---"}</td>
</tr>
))}
{sorted.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-10 text-gray-400 dark:text-gray-500 text-sm">
No skills found matching the filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -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<SkillRule[]>([]);
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<SkillRule>) {
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 (
<div className="space-y-4">
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-5 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200">People Finder</h2>
<span className="text-xs text-gray-400 dark:text-gray-500">Find resources that match skill criteria</span>
</div>
{/* Datalist */}
<datalist id={datalistId}>
{allSkillNames.map((s) => <option key={s} value={s} />)}
</datalist>
{/* Rules */}
<div className="space-y-2">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2 flex-wrap">
{idx > 0 ? (
<button
type="button"
onClick={() => setOperator((op) => (op === "AND" ? "OR" : "AND"))}
className="w-12 text-center text-xs font-bold px-2 py-1.5 rounded-lg border border-brand-200 dark:border-brand-700 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 hover:bg-brand-100 dark:hover:bg-brand-900/50 transition-colors shrink-0"
>
{operator}
</button>
) : (
<span className="w-12 text-center text-xs font-medium text-gray-400 dark:text-gray-500 shrink-0">knows</span>
)}
<input
type="text"
list={datalistId}
placeholder="Skill name..."
value={rule.skill}
onChange={(e) => 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"
/>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">min.</span>
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-slate-600">
{[1, 2, 3, 4, 5].map((lvl) => (
<button
key={lvl}
type="button"
title={PROFICIENCY_LABELS[lvl]}
onClick={() => updateRule(idx, { minProficiency: lvl })}
className={`px-2 py-1 text-xs font-medium transition-colors ${
rule.minProficiency === lvl
? "bg-brand-600 text-white"
: "bg-white dark:bg-slate-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700"
}`}
>
{lvl}
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => removeRule(idx)}
className="text-gray-400 hover:text-red-500 transition-colors p-1"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
{/* Controls row */}
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={addRule} className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-dashed border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add skill rule
</button>
{rules.length > 1 && (
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500 dark:text-gray-400">Match:</span>
{(["AND", "OR"] as const).map((op) => (
<button
key={op}
type="button"
onClick={() => setOperator(op)}
className={`px-2.5 py-1 text-xs font-medium border transition-colors ${
op === "AND" ? "rounded-l-lg" : "rounded-r-lg -ml-px"
} ${operator === op
? "bg-brand-600 border-brand-600 text-white"
: "bg-white dark:bg-slate-800 border-gray-200 dark:border-slate-600 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700"
}`}
>
{op === "AND" ? "All (AND)" : "Any (OR)"}
</button>
))}
</div>
)}
{allChapters.length > 0 && (
<div className="flex items-center gap-1.5 ml-auto">
<span className="text-xs text-gray-500 dark:text-gray-400">Chapter:</span>
<select
value={chapter}
onChange={(e) => setChapter(e.target.value)}
className="px-2 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"
>
<option value="">All chapters</option>
{allChapters.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
)}
{results && results.length > 0 && (
<button type="button" onClick={exportXlsx} className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-slate-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors">
Export XLSX
</button>
)}
</div>
{/* Results */}
{activeRules.length > 0 && (
<div className="border-t border-gray-100 dark:border-slate-700 pt-4">
{isFetching ? (
<div className="text-sm text-gray-400 animate-pulse">Searching...</div>
) : results && results.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-gray-500 italic">No resources match these criteria.</p>
) : results && results.length > 0 ? (
<>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{results.length} resource{results.length !== 1 ? "s" : ""} found
</p>
<div className="space-y-2">
{results.map((person) => (
<div key={person.id} className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-slate-800/50 hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link href={`/resources/${person.id}`} className="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-brand-600 transition-colors">
{person.displayName}
</Link>
{person.eid && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300">{person.eid}</span>
)}
{person.chapter && (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-100">{person.chapter}</span>
)}
</div>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{person.matchedSkills.map((s) => (
<span key={s.skill} className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border ${proficiencyClasses(s.proficiency)}`}>
{s.skill} <span className="font-semibold">{s.proficiency}</span>
</span>
))}
</div>
</div>
<Link href={`/resources/${person.id}`} className="text-xs text-brand-600 dark:text-brand-400 hover:underline shrink-0 mt-0.5">
View
</Link>
</div>
))}
</div>
</>
) : null}
</div>
)}
</div>
</div>
);
}
@@ -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 (
<div className="space-y-4">
{/* Filters */}
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-5 space-y-4">
<div className="flex flex-wrap gap-3 items-center">
{/* Search input */}
<div className="relative">
<svg
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
</svg>
<input
type="text"
placeholder="Search by skill name..."
value={searchSkill}
onChange={(e) => 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"
/>
</div>
{/* Min proficiency */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500 dark:text-gray-400">Min. proficiency:</span>
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-slate-600">
{[1, 2, 3, 4, 5].map((lvl) => (
<button
key={lvl}
type="button"
title={PROFICIENCY_LABELS[lvl]}
onClick={() => setMinProficiency(lvl)}
className={`px-2 py-1 text-xs font-medium transition-colors ${
minProficiency === lvl
? "bg-brand-600 text-white"
: "bg-white dark:bg-slate-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700"
}`}
>
{lvl}
</button>
))}
</div>
</div>
{/* Available only */}
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300 cursor-pointer select-none">
<input
type="checkbox"
checked={availableOnly}
onChange={(e) => setAvailableOnly(e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Available in next 30 days
</label>
</div>
{/* Results */}
{debouncedSearch.trim().length > 0 && (
<div className="border-t border-gray-100 dark:border-slate-700 pt-4">
{isLoading ? (
<div className="text-sm text-gray-400 animate-pulse">Searching...</div>
) : sorted.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-gray-500 italic">
No resources found with &quot;{debouncedSearch}&quot; at proficiency {minProficiency}+.
</p>
) : (
<>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{sorted.length} resource{sorted.length !== 1 ? "s" : ""} found
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
<tr>
<SortableColumnHeader label="Resource" field="displayName" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<SortableColumnHeader label="Chapter" field="chapter" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<SortableColumnHeader label="Skill" field="skillName" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<SortableColumnHeader label="Proficiency" field="skillProficiency" sortField={sortField} sortDir={sortDir} onSort={toggle} align="center" />
<SortableColumnHeader label="Utilization" field="utilizationPercent" sortField={sortField} sortDir={sortDir} onSort={toggle} align="right" />
<SortableColumnHeader label="Available From" field="availableFrom" sortField={sortField} sortDir={sortDir} onSort={toggle} />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
{sorted.map((r) => (
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
<td className="px-4 py-2.5">
<Link href={`/resources/${r.id}`} className="font-medium text-gray-900 dark:text-gray-100 hover:text-brand-600 transition-colors">
{r.displayName}
</Link>
</td>
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400">{r.chapter ?? "---"}</td>
<td className="px-4 py-2.5 text-gray-700 dark:text-gray-300">{r.skillName}</td>
<td className="px-4 py-2.5 text-center"><ProficiencyBadge value={r.skillProficiency} /></td>
<td className="px-4 py-2.5 text-right">
<span className={`text-sm font-medium ${
r.utilizationPercent >= 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}%
</span>
</td>
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-sm">{formatDate(r.availableFrom)}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)}
</div>
</div>
);
}
@@ -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 (
<span className={`inline-block px-2 py-0.5 text-xs rounded font-medium border ${proficiencyClasses(value)}`}>
{value} {PROFICIENCY_LABELS[value] ?? ""}
</span>
);
}
export function GapIndicator({ gap }: { gap: number }) {
if (gap > 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-red-100 text-red-700 border border-red-200 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700">
-{gap} shortage
</span>
);
}
if (gap < 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-green-100 text-green-700 border border-green-200 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700">
+{Math.abs(gap)} surplus
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-gray-100 text-gray-500 border border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600">
balanced
</span>
);
}
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" });
}
@@ -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<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
// -- Custom field form state --
const [showCustomForm, setShowCustomForm] = useState(false);
const [customKey, setCustomKey] = useState("");
const [customLabel, setCustomLabel] = useState("");
const [customType, setCustomType] = useState<FieldType>(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<string, FieldOverrides>
>(() => {
const map: Record<string, FieldOverrides> = {};
// 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<FieldState[]>(() => {
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<string, CatalogField[]>();
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<HTMLDivElement>) {
if (e.target === e.currentTarget) onClose();
}
// ---------------------------------------------------------------------------
// Collapsed categories
// ---------------------------------------------------------------------------
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(
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 (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={handleBackdropClick}
>
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-4xl mx-4 flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Configure Fields:{" "}
<span className="text-gray-600 dark:text-gray-400 font-normal">{blueprintName}</span>
</h2>
<p className="text-xs text-gray-400 mt-0.5">
{enabledCount} field{enabledCount !== 1 ? "s" : ""} enabled
</p>
</div>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-2xl leading-none"
aria-label="Close"
>
x
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200 dark:border-gray-700 px-6 shrink-0">
{(["fields", "presets"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === tab
? "border-brand-500 text-brand-600"
: "border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
{tab === "fields" ? "Fields" : "Role Presets"}
</button>
))}
</div>
{activeTab === "fields" ? (
<>
{/* Search + category sidebar layout */}
<div className="flex flex-1 overflow-hidden">
{/* Category sidebar */}
<div className="w-48 shrink-0 border-r border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-900 overflow-y-auto hidden md:block">
<nav className="py-2">
<button
type="button"
onClick={() => setActiveCategory(null)}
className={`w-full text-left px-4 py-2 text-sm transition-colors ${
activeCategory === null
? "bg-brand-100 dark:bg-gray-800 text-brand-700 dark:text-brand-400 font-medium border-l-2 border-brand-500"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 border-l-2 border-transparent"
}`}
>
All Fields
</button>
{allCategoryNames.map((name) => {
const catCount =
name === "Custom Fields"
? customFields.length
: (fieldsByCategory.get(name)?.length ?? 0);
const enabledInCat =
name === "Custom Fields"
? customFields.filter((f) => f.overrides.enabled).length
: (fieldsByCategory.get(name) ?? []).filter(
(f) => catalogOverrides[f.key]?.enabled,
).length;
return (
<button
key={name}
type="button"
onClick={() => setActiveCategory(name)}
className={`w-full text-left px-4 py-2 text-sm transition-colors ${
activeCategory === name
? "bg-brand-100 dark:bg-gray-800 text-brand-700 dark:text-brand-400 font-medium border-l-2 border-brand-500"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 border-l-2 border-transparent"
}`}
>
<span className="truncate block">{name}</span>
{catCount > 0 && (
<span className="text-xs text-gray-400">
{enabledInCat}/{catCount}
</span>
)}
</button>
);
})}
</nav>
</div>
{/* Main content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Search bar */}
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-700 shrink-0">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search fields..."
className={`${INPUT_CLS} w-full`}
autoFocus
/>
</div>
{/* Field cards */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
{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 (
<div key={cat.name}>
<button
type="button"
onClick={() => toggleCategory(cat.name)}
className="flex items-center gap-2 mb-3 w-full text-left group"
>
<span className="text-xs text-gray-400 transition-transform group-hover:text-gray-600">
{isCollapsed ? "\u25B6" : "\u25BC"}
</span>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
{cat.name}
</h3>
<span className="text-xs text-gray-400">
{cat.description}
</span>
</button>
{!isCollapsed && (
<div className="grid grid-cols-1 gap-2">
{fields.map((field) => (
<FieldCard
key={field.key}
field={field}
overrides={catalogOverrides[field.key]!}
onChange={(ov) =>
handleCatalogFieldChange(field.key, ov)
}
/>
))}
{fields.length === 0 && (
<p className="text-sm text-gray-400 py-2">
No fields in this category.
</p>
)}
</div>
)}
</div>
);
})}
{/* Custom Fields section */}
{(activeCategory === null ||
activeCategory === "Custom Fields") && (
<div>
<button
type="button"
onClick={() => toggleCategory("Custom Fields")}
className="flex items-center gap-2 mb-3 w-full text-left group"
>
<span className="text-xs text-gray-400 transition-transform group-hover:text-gray-600">
{collapsedCategories.has("Custom Fields")
? "\u25B6"
: "\u25BC"}
</span>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Custom Fields
</h3>
<span className="text-xs text-gray-400">
User-defined fields not in the catalog
</span>
</button>
{!collapsedCategories.has("Custom Fields") && (
<div className="space-y-2">
{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 (
<div key={cf.custom.key} className="relative">
<FieldCard
field={pseudoCatalog}
overrides={cf.overrides}
onChange={(ov) =>
handleCustomFieldChange(idx, ov)
}
/>
<button
type="button"
onClick={() => removeCustomField(idx)}
className="absolute top-3 right-14 text-xs text-red-400 hover:text-red-600"
title="Remove custom field"
>
Remove
</button>
</div>
);
})}
{/* Add custom field */}
{showCustomForm ? (
<div className="border border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-4 space-y-3 bg-gray-50/50 dark:bg-gray-800">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">
Key{" "}
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={customKey}
onChange={(e) =>
setCustomKey(
e.target.value.replace(
/[^a-zA-Z0-9_]/g,
"",
),
)
}
placeholder="field_key"
className={`${INPUT_CLS} font-mono`}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">
Label{" "}
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={customLabel}
onChange={(e) =>
setCustomLabel(e.target.value)
}
placeholder="Display Label"
className={INPUT_CLS}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">
Type
</label>
<select
value={customType}
onChange={(e) =>
setCustomType(
e.target.value as FieldType,
)
}
className={INPUT_CLS}
>
{FIELD_TYPES.map((ft) => (
<option key={ft.value} value={ft.value}>
{ft.label}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={addCustomField}
disabled={
!customKey.trim() || !customLabel.trim()
}
className={BTN_PRIMARY}
>
Add
</button>
<button
type="button"
onClick={() => {
setShowCustomForm(false);
setCustomKey("");
setCustomLabel("");
setCustomType(FieldType.TEXT);
}}
className={BTN_SECONDARY}
>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowCustomForm(true)}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium py-2"
>
<span className="text-lg leading-none">+</span>{" "}
Add Custom Field
</button>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Error */}
{saveError && (
<div className="mx-6 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700 shrink-0">
{saveError}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0">
<span className="text-xs text-gray-400">
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be
saved
</span>
<div className="flex items-center gap-3">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={updateMutation.isPending}
className={BTN_PRIMARY}
>
{updateMutation.isPending ? "Saving..." : "Save Fields"}
</button>
</div>
</div>
</>
) : (
<div className="px-6 py-4 overflow-y-auto">
<p className="text-xs text-gray-500 mb-4">
Role presets are auto-loaded in Step 3 of the Project Creation
Wizard when this blueprint is selected.
</p>
<RolePresetsEditor
initialPresets={initialRolePresets}
onSave={(presets) =>
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}
/>
<div className="flex justify-start mt-4 border-t border-gray-200 pt-4">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
Close
</button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -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 (
<tr key={bp.id} className="border-b border-gray-100 last:border-b-0 hover:bg-gray-50 transition-colors">
<tr key={bp.id} className="border-b border-gray-100 dark:border-gray-700/50 last:border-b-0 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<td className="px-3 py-3">
<input
type="checkbox"
@@ -380,20 +380,20 @@ export function BlueprintsClient() {
</td>
<td className="px-3 py-3">
<div className="min-w-0">
<div className="font-medium text-gray-900">{bp.name}</div>
<div className="font-medium text-gray-900 dark:text-gray-100">{bp.name}</div>
{bp.description && <div className="text-xs text-gray-500 mt-0.5 truncate">{bp.description}</div>}
</div>
</td>
<td className="px-3 py-3">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700" : "bg-blue-50 text-blue-700"}`}>
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300" : "bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"}`}>
{bp.target}
</span>
</td>
<td className="px-3 py-3 text-center text-gray-600">{fieldCount}</td>
<td className="px-3 py-3 text-center text-gray-600">{isProject ? presetCount : "—"}</td>
<td className="px-3 py-3 text-center text-gray-600 dark:text-gray-400">{fieldCount}</td>
<td className="px-3 py-3 text-center text-gray-600 dark:text-gray-400">{isProject ? presetCount : "—"}</td>
<td className="px-3 py-3 text-center">
{bp.isGlobal ? (
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 font-medium">
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 font-medium">
Global
</span>
) : (
@@ -496,9 +496,10 @@ export function BlueprintsClient() {
)}
{editingBlueprint && (
<BlueprintFieldEditor
<BlueprintFieldCatalog
blueprintId={editingBlueprint.id}
blueprintName={editingBlueprint.name}
blueprintTarget={editingBlueprint.target}
initialFieldDefs={Array.isArray(editingBlueprint.fieldDefs) ? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[]) : []}
initialRolePresets={Array.isArray(editingBlueprint.rolePresets) ? (editingBlueprint.rolePresets as import("@planarchy/shared").StaffingRequirement[]) : []}
initialTab={editingTab}
@@ -0,0 +1,383 @@
"use client";
import { useState } from "react";
import { FieldType } from "@planarchy/shared";
import type { FieldOption } from "@planarchy/shared";
import type { CatalogField } from "~/lib/blueprint-field-catalog.js";
// ---------------------------------------------------------------------------
// Type icons
// ---------------------------------------------------------------------------
const TYPE_ICONS: Record<FieldType, string> = {
[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, string> = {
[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<FieldOverrides>) {
onChange({ ...overrides, ...patch });
}
function handleToggle() {
const next = !overrides.enabled;
update({ enabled: next });
if (!next) {
setExpanded(false);
}
}
const isActive = overrides.enabled;
return (
<div
className={`border rounded-lg transition-all ${
isActive
? "border-brand-400/60 bg-brand-50/40 shadow-sm dark:border-brand-600/50 dark:bg-brand-950/50"
: "border-gray-200 bg-white dark:border-gray-700/50 dark:bg-gray-800"
}`}
>
{/* Header row */}
<div
className="flex items-center gap-3 px-4 py-3 cursor-pointer select-none"
onClick={() => {
if (isActive) setExpanded((v) => !v);
else handleToggle();
}}
>
{/* Type icon */}
<span
className={`w-8 h-8 rounded-md flex items-center justify-center text-sm font-bold shrink-0 ${
isActive
? "bg-brand-100 text-brand-700 dark:bg-brand-900/50 dark:text-brand-300"
: "bg-gray-100 text-gray-400 dark:bg-gray-700 dark:text-gray-500"
}`}
title={TYPE_LABELS[field.type]}
>
{TYPE_ICONS[field.type]}
</span>
{/* Label + description */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span
className={`font-medium text-sm truncate ${
isActive ? "text-gray-900 dark:text-gray-100" : "text-gray-500 dark:text-gray-400"
}`}
>
{field.label}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono hidden sm:inline">
{field.key}
</span>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">{field.description}</p>
</div>
{/* Toggle switch */}
<button
type="button"
role="switch"
aria-checked={isActive}
aria-label={`Toggle ${field.label}`}
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 ${
isActive ? "bg-brand-600" : "bg-gray-300 dark:bg-gray-600"
}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transform transition-transform duration-200 ${
isActive ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
{/* Expanded settings */}
{isActive && expanded && (
<div className="px-4 pb-4 pt-1 border-t border-brand-200/50 space-y-3">
{/* Default value input */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600 dark:text-gray-300">
Default Value
</label>
<DefaultValueInput
type={field.type}
{...(field.options ? { options: field.options } : {})}
value={overrides.defaultValue}
onChange={(val) => update({ defaultValue: val })}
/>
</div>
{/* Toggles row */}
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-200 cursor-pointer select-none">
<input
type="checkbox"
checked={overrides.required}
onChange={(e) => update({ required: e.target.checked })}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Required
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-200 cursor-pointer select-none">
<input
type="checkbox"
checked={overrides.showInList}
onChange={(e) => update({ showInList: e.target.checked })}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Show in list
</label>
</div>
{/* Description override */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600 dark:text-gray-300">
Helper Text
</label>
<input
type="text"
value={overrides.description}
onChange={(e) => update({ description: e.target.value })}
placeholder={field.description}
className={INPUT_CLS}
/>
</div>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer select-none">
<input
type="checkbox"
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
{value ? "True" : "False"}
</label>
);
case FieldType.NUMBER:
return (
<input
type="number"
value={value != null ? String(value) : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : Number(e.target.value))
}
placeholder="No default"
className={INPUT_CLS}
/>
);
case FieldType.DATE:
return (
<input
type="date"
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
className={INPUT_CLS}
/>
);
case FieldType.SELECT:
return (
<select
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
className={INPUT_CLS}
>
<option value="">No default</option>
{(options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
case FieldType.MULTI_SELECT:
return (
<MultiSelectDefaultInput
options={options ?? []}
value={Array.isArray(value) ? (value as string[]) : []}
onChange={onChange}
/>
);
case FieldType.URL:
return (
<input
type="url"
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
placeholder="https://..."
className={INPUT_CLS}
/>
);
case FieldType.EMAIL:
return (
<input
type="email"
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
placeholder="name@example.com"
className={INPUT_CLS}
/>
);
case FieldType.TEXTAREA:
return (
<textarea
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
placeholder="No default"
className={`${INPUT_CLS} resize-none`}
rows={2}
/>
);
default:
return (
<input
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
placeholder="No default"
className={INPUT_CLS}
/>
);
}
}
// ---------------------------------------------------------------------------
// Multi-select checkboxes for default value
// ---------------------------------------------------------------------------
function MultiSelectDefaultInput({
options,
value,
onChange,
}: {
options: FieldOption[];
value: string[];
onChange: (val: string[]) => void;
}) {
function toggleOption(optValue: string) {
const next = value.includes(optValue)
? value.filter((v) => v !== optValue)
: [...value, optValue];
onChange(next.length > 0 ? next : []);
}
if (options.length === 0) {
return <span className="text-xs text-gray-400">No options defined</span>;
}
return (
<div className="flex flex-wrap gap-2">
{options.map((opt) => (
<label
key={opt.value}
className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-200 cursor-pointer select-none"
>
<input
type="checkbox"
checked={value.includes(opt.value)}
onChange={() => toggleOption(opt.value)}
className="w-3.5 h-3.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
{opt.label}
</label>
))}
</div>
);
}
@@ -3,6 +3,7 @@
import { useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { CommentInput } from "./CommentInput.js";
interface CommentAuthor {
@@ -118,6 +119,7 @@ function SingleComment({
isReply?: boolean;
}) {
const [showReplyInput, setShowReplyInput] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const utils = trpc.useUtils();
const createMutation = trpc.comment.create.useMutation({
@@ -199,11 +201,7 @@ function SingleComment({
)}
<button
type="button"
onClick={() => {
if (window.confirm("Delete this comment?")) {
deleteMutation.mutate({ id: comment.id });
}
}}
onClick={() => setConfirmDelete(true)}
disabled={deleteMutation.isPending}
className="text-xs text-gray-400 hover:text-rose-600 dark:hover:text-rose-400"
>
@@ -236,6 +234,20 @@ function SingleComment({
</div>
</div>
{confirmDelete && (
<ConfirmDialog
title="Delete comment"
message="Are you sure you want to delete this comment?"
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate({ id: comment.id });
setConfirmDelete(false);
}}
onCancel={() => setConfirmDelete(false)}
/>
)}
{/* Render replies */}
{"replies" in comment && comment.replies.length > 0 && (
<div className="mt-3 space-y-3 border-l-2 border-gray-100 dark:border-gray-700 pl-2">
@@ -157,6 +157,7 @@ export function DashboardClient() {
<div key={widget.id}>
<WidgetContainer
title={widget.title ?? getWidget(widget.type).label}
description={getWidget(widget.type).description}
onRemove={() => removeWidget(widget.id)}
>
{renderWidget(widget.type, widget.config, (update) =>
@@ -4,31 +4,54 @@ import { motion } from "framer-motion";
interface WidgetContainerProps {
title: string;
description?: string;
onRemove: () => void;
children: React.ReactNode;
isDragging?: boolean;
}
export function WidgetContainer({ title, onRemove, children, isDragging }: WidgetContainerProps) {
export function WidgetContainer({ title, description, onRemove, children, isDragging }: WidgetContainerProps) {
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className={`flex flex-col h-full bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden transition-colors duration-200 ${
isDragging ? "shadow-lg border-brand-300" : "hover:border-brand-200 dark:hover:border-brand-800"
className={`flex flex-col h-full rounded-xl border overflow-hidden transition-all duration-200 ${
isDragging
? "shadow-xl border-brand-400 dark:border-brand-500 scale-[1.01] ring-2 ring-brand-400/30"
: "bg-white dark:bg-gray-900 border-gray-200/80 dark:border-gray-700/60 shadow-sm hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600"
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-100 bg-gray-50/50 shrink-0 cursor-grab active:cursor-grabbing widget-drag-handle">
<span className="text-sm font-semibold text-gray-700 truncate">{title}</span>
{/* Header — clean, no background separation */}
<div className="flex items-center justify-between px-4 pt-3.5 pb-2 shrink-0 cursor-grab active:cursor-grabbing widget-drag-handle group">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{/* Drag grip dots */}
<svg
className="w-3.5 h-5 text-gray-300 dark:text-gray-600 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
viewBox="0 0 14 20"
fill="currentColor"
>
<circle cx="4" cy="4" r="1.5" />
<circle cx="10" cy="4" r="1.5" />
<circle cx="4" cy="10" r="1.5" />
<circle cx="10" cy="10" r="1.5" />
<circle cx="4" cy="16" r="1.5" />
<circle cx="10" cy="16" r="1.5" />
</svg>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">{title}</span>
</div>
{description && (
<p className="text-[11px] text-gray-400 dark:text-gray-500 truncate mt-0.5 ml-[22px]">{description}</p>
)}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="ml-2 p-1 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded transition-colors shrink-0"
className="ml-2 p-1.5 text-gray-300 dark:text-gray-600 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg transition-colors shrink-0 opacity-0 group-hover:opacity-100"
title="Remove widget"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -37,6 +60,9 @@ export function WidgetContainer({ title, onRemove, children, isDragging }: Widge
</button>
</div>
{/* Subtle separator */}
<div className="mx-4 border-t border-gray-100 dark:border-gray-800" />
{/* Body */}
<div className="flex-1 overflow-auto p-4">{children}</div>
</motion.div>
@@ -0,0 +1,114 @@
"use client";
import { useMemo } from "react";
// ─── Types ──────────────────────────────────────────────────────────────────
export interface WidgetFilter {
type: "search" | "select" | "toggle";
key: string;
label?: string;
placeholder?: string;
options?: { value: string; label: string }[];
}
interface WidgetFilterBarProps {
filters: WidgetFilter[];
values: Record<string, unknown>;
onChange: (update: Record<string, unknown>) => void;
}
// ─── Component ──────────────────────────────────────────────────────────────
export function WidgetFilterBar({ filters, values, onChange }: WidgetFilterBarProps) {
const hasActiveFilters = useMemo(() => {
return filters.some((f) => {
const v = values[f.key];
if (f.type === "toggle") return v === true;
return typeof v === "string" && v.length > 0;
});
}, [filters, values]);
return (
<div className="flex flex-wrap items-center gap-1.5 px-1 pb-2">
{filters.map((filter) => {
switch (filter.type) {
case "search":
return (
<div key={filter.key} className="relative">
<svg
className="absolute left-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-400 dark:text-gray-500 pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
</svg>
<input
type="text"
value={(values[filter.key] as string) ?? ""}
onChange={(e) => onChange({ [filter.key]: e.target.value })}
placeholder={filter.placeholder ?? "Search..."}
className="pl-6 pr-2 py-1 w-32 text-[11px] border border-gray-200 dark:border-gray-700 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
);
case "select":
return (
<select
key={filter.key}
value={(values[filter.key] as string) ?? ""}
onChange={(e) => onChange({ [filter.key]: e.target.value })}
className="py-1 px-1.5 text-[11px] border border-gray-200 dark:border-gray-700 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-brand-500 min-w-[80px] max-w-[130px]"
title={filter.label}
>
<option value="">{filter.label ?? "All"}</option>
{(filter.options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
case "toggle":
return (
<label
key={filter.key}
className="flex items-center gap-1 text-[11px] text-gray-600 dark:text-gray-400 cursor-pointer select-none"
>
<input
type="checkbox"
checked={!!values[filter.key]}
onChange={(e) => onChange({ [filter.key]: e.target.checked })}
className="rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-500 h-3 w-3"
/>
{filter.label}
</label>
);
default:
return null;
}
})}
{hasActiveFilters && (
<button
type="button"
onClick={() => {
const reset: Record<string, unknown> = {};
for (const f of filters) {
reset[f.key] = f.type === "toggle" ? false : "";
}
onChange(reset);
}}
className="text-[10px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 px-1"
title="Reset filters"
>
Reset
</button>
)}
</div>
);
}
@@ -1,7 +1,11 @@
"use client";
import { useMemo } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
function colorClass(pct: number): string {
if (pct > 90) return "bg-red-500";
@@ -15,12 +19,34 @@ function textColorClass(pct: number): string {
return "text-green-700";
}
export function BudgetForecastWidget(_props: WidgetProps) {
export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
const { clients } = useWidgetFilterOptions();
const filters = useMemo<WidgetFilter[]>(
() => [
{ type: "search", key: "search", placeholder: "Search project..." },
{ type: "select", key: "clientId", label: "Client", options: clients },
],
[clients],
);
const { data, isLoading } = trpc.dashboard.getBudgetForecast.useQuery(
undefined,
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
const search = ((config.search as string) ?? "").toLowerCase();
const clientId = (config.clientId as string) ?? "";
const rows = useMemo(() => {
const all = data ?? [];
return all.filter((r) => {
if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false;
if (clientId && r.clientId !== clientId) return false;
return true;
});
}, [data, search, clientId]);
if (isLoading && !data) {
return (
<div className="flex flex-col gap-1 pt-1">
@@ -35,59 +61,71 @@ export function BudgetForecastWidget(_props: WidgetProps) {
);
}
const rows = data ?? [];
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No active projects with budgets.
<div className="flex flex-col h-full">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="flex items-center justify-center flex-1 text-sm text-gray-400">
No active projects with budgets.
</div>
</div>
);
}
return (
<div className="overflow-auto h-full">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">Project</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Budget Usage</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Burn/mo</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Exhaustion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900 max-w-[140px] truncate">
<span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>
{row.projectName}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${colorClass(row.pctUsed)}`}
style={{ width: `${Math.min(row.pctUsed, 100)}%` }}
/>
</div>
<span className={`text-[11px] font-semibold tabular-nums w-10 text-right ${textColorClass(row.pctUsed)}`}>
{row.pctUsed}%
</span>
</div>
</td>
<td className="px-3 py-2 text-right text-gray-700 tabular-nums">
{row.burnRate > 0
? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC`
: "\u2014"}
</td>
<td className="px-3 py-2 text-right text-gray-500 tabular-nums">
{row.estimatedExhaustionDate ?? "\u2014"}
</td>
<div className="flex flex-col h-full overflow-hidden">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="overflow-auto flex-1">
<table className="w-full text-xs">
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
Project <InfoTooltip content="Active projects with a defined budget" />
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
Budget Usage <InfoTooltip content="Percentage of total budget consumed by current allocations" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Burn/mo <InfoTooltip content="Monthly burn rate based on currently active allocations" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Exhaustion <InfoTooltip content="Projected date when budget will be fully consumed at the current burn rate" />
</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[140px] truncate">
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${colorClass(row.pctUsed)}`}
style={{ width: `${Math.min(row.pctUsed, 100)}%` }}
/>
</div>
<span className={`text-[11px] font-semibold tabular-nums w-10 text-right ${textColorClass(row.pctUsed)}`}>
{row.pctUsed}%
</span>
</div>
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
{row.burnRate > 0
? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC`
: "\u2014"}
</td>
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400 tabular-nums">
{row.estimatedExhaustionDate ?? "\u2014"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -5,6 +5,8 @@ import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { AnimatedNumber } from "~/components/ui/AnimatedNumber.js";
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
function UtilizationBar({ percent }: { percent: number }) {
const barColor =
@@ -71,9 +73,20 @@ function FilterDropdown({ label, children }: { label: string; children: ReactNod
);
}
export function ChargeabilityWidget({ config: _config }: WidgetProps) {
const config = _config as { topN?: number; watchlistThreshold?: number };
const [includeProposed, setIncludeProposed] = useState(false);
export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetProps) {
const config = _config as { topN?: number; watchlistThreshold?: number; chapter?: string; includeProposed?: boolean };
const { chapters } = useWidgetFilterOptions();
const widgetFilters = useMemo<WidgetFilter[]>(
() => [
{ type: "select", key: "chapter", label: "Chapter", options: chapters },
{ type: "toggle", key: "includeProposed", label: "Include Proposed" },
],
[chapters],
);
const includeProposed = !!config.includeProposed;
const chapterFilter = (config.chapter as string) ?? "";
const [showDeparted, setShowDeparted] = useState(false);
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
const [topSort, setTopSort] = useState<TopSortKey>("actual");
@@ -132,6 +145,23 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
setWatchVisibleCount(batchSize);
}, [batchSize, includeProposed, selectedCountryIds, showDeparted]);
// These useMemo hooks MUST be before any early return to respect Rules of Hooks
const rawTop = data?.top ?? [];
const rawWatch = data?.watchlist ?? [];
const month = (data?.month as string) ?? "";
const filteredTop = useMemo(() => {
const arr = rawTop as ChargeabilityRow[];
if (!chapterFilter) return arr;
return arr.filter((r) => r.chapter === chapterFilter);
}, [rawTop, chapterFilter]);
const filteredWatch = useMemo(() => {
const arr = rawWatch as ChargeabilityRow[];
if (!chapterFilter) return arr;
return arr.filter((r) => r.chapter === chapterFilter);
}, [rawWatch, chapterFilter]);
if (isLoading) {
return (
<div className="flex flex-col gap-3 pt-1">
@@ -158,11 +188,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
);
}
const rawTop = data?.top ?? [];
const rawWatch = data?.watchlist ?? [];
const month = (data?.month as string) ?? "";
const top = ([...rawTop] as ChargeabilityRow[]).sort((a, b) => {
const top = ([...filteredTop]).sort((a, b) => {
const mult = topDir === "asc" ? 1 : -1;
switch (topSort) {
case "name":
@@ -176,7 +202,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
}
});
const watchlist = ([...rawWatch] as ChargeabilityRow[]).sort((a, b) => {
const watchlist = ([...filteredWatch]).sort((a, b) => {
const mult = watchDir === "asc" ? 1 : -1;
switch (watchSort) {
case "name":
@@ -233,9 +259,10 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
return (
<div className="h-full flex flex-col gap-2 overflow-hidden">
{month && (
<div className="px-1 flex-shrink-0 flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<div className="px-1 flex-shrink-0 flex flex-col gap-2">
<WidgetFilterBar filters={widgetFilters} values={_config} onChange={onConfigChange ?? (() => {})} />
<div className="flex items-center justify-between gap-3">
{month && (
<p className="text-xs text-gray-400 flex items-center gap-1">
Period: {month}
<InfoTooltip
@@ -243,66 +270,56 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
width="w-72"
/>
</p>
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
<input
type="checkbox"
checked={includeProposed}
onChange={(event) => setIncludeProposed(event.target.checked)}
className="rounded border-gray-300"
/>
Include proposed
<InfoTooltip content="When enabled, PROPOSED bookings and imported TBD planning rows are also counted toward chargeability." />
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
<input
type="checkbox"
checked={showDeparted}
onChange={(event) => setShowDeparted(event.target.checked)}
className="rounded border-gray-300"
/>
Show departed
<InfoTooltip content="When enabled, resources who have left the company are included in the lists." />
</label>
<FilterDropdown label={selectedCountryLabel}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-gray-600">Countries</p>
{selectedCountryIds.length > 0 ? (
<button
type="button"
onClick={() => setSelectedCountryIds([])}
className="text-[11px] text-brand-600 hover:text-brand-700"
>
Clear
</button>
) : null}
</div>
<p className="text-[11px] text-gray-400">
Empty selection means all countries are included.
</p>
<div className="max-h-48 space-y-1 overflow-y-auto pr-1">
{countries.map((country) => (
<label
key={country.id}
className="flex items-center gap-2 text-xs text-gray-700"
>
<input
type="checkbox"
checked={selectedCountryIds.includes(country.id)}
onChange={(event) => toggleCountry(country.id, event.target.checked)}
className="rounded border-gray-300"
/>
<span>{country.name}</span>
</label>
))}
</div>
</div>
</FilterDropdown>
</div>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
<input
type="checkbox"
checked={showDeparted}
onChange={(event) => setShowDeparted(event.target.checked)}
className="rounded border-gray-300"
/>
Show departed
<InfoTooltip content="When enabled, resources who have left the company are included in the lists." />
</label>
<FilterDropdown label={selectedCountryLabel}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-gray-600">Countries</p>
{selectedCountryIds.length > 0 ? (
<button
type="button"
onClick={() => setSelectedCountryIds([])}
className="text-[11px] text-brand-600 hover:text-brand-700"
>
Clear
</button>
) : null}
</div>
<p className="text-[11px] text-gray-400">
Empty selection means all countries are included.
</p>
<div className="max-h-48 space-y-1 overflow-y-auto pr-1">
{countries.map((country) => (
<label
key={country.id}
className="flex items-center gap-2 text-xs text-gray-700"
>
<input
type="checkbox"
checked={selectedCountryIds.includes(country.id)}
onChange={(event) => toggleCountry(country.id, event.target.checked)}
className="rounded border-gray-300"
/>
<span>{country.name}</span>
</label>
))}
</div>
</div>
</FilterDropdown>
</div>
</div>
{/* Top list */}
<section
@@ -1,7 +1,13 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ShoringBadge } from "~/components/projects/ShoringIndicator.js";
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
function healthDot(value: number): string {
if (value >= 70) return "bg-green-500";
@@ -10,17 +16,39 @@ function healthDot(value: number): string {
}
function scoreBadge(score: number): string {
if (score >= 70) return "bg-green-100 text-green-700";
if (score >= 40) return "bg-amber-100 text-amber-700";
return "bg-red-100 text-red-700";
if (score >= 70) return "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300";
if (score >= 40) return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300";
return "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300";
}
export function ProjectHealthWidget(_props: WidgetProps) {
export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
const { clients } = useWidgetFilterOptions();
const filters = useMemo<WidgetFilter[]>(
() => [
{ type: "search", key: "search", placeholder: "Search project..." },
{ type: "select", key: "clientId", label: "Client", options: clients },
],
[clients],
);
const { data, isLoading } = trpc.dashboard.getProjectHealth.useQuery(
undefined,
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
const search = ((config.search as string) ?? "").toLowerCase();
const clientId = (config.clientId as string) ?? "";
const rows = useMemo(() => {
const all = data ?? [];
return all.filter((r) => {
if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false;
if (clientId && r.clientId !== clientId) return false;
return true;
});
}, [data, search, clientId]);
if (isLoading && !data) {
return (
<div className="flex flex-col gap-1 pt-1">
@@ -40,62 +68,78 @@ export function ProjectHealthWidget(_props: WidgetProps) {
);
}
const rows = data ?? [];
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No active projects found.
<div className="flex flex-col h-full">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="flex items-center justify-center flex-1 text-sm text-gray-400">
No active projects found.
</div>
</div>
);
}
return (
<div className="overflow-auto h-full">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">Project</th>
<th className="px-3 py-2 text-center font-medium text-gray-500" title="Budget / Staffing / Timeline">
B / S / T
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Score</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900 max-w-[160px] truncate">
<span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>
{row.projectName}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
title={`Budget: ${row.budgetHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
title={`Staffing: ${row.staffingHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
title={`Timeline: ${row.timelineHealth}%`}
/>
</div>
</td>
<td className="px-3 py-2 text-right">
<span
className={`inline-block px-2 py-0.5 rounded-full font-semibold tabular-nums ${scoreBadge(row.compositeScore)}`}
>
{row.compositeScore}
</span>
</td>
<div className="flex flex-col h-full overflow-hidden">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="overflow-auto flex-1">
<table className="w-full text-xs">
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
Project <InfoTooltip content="Active projects scored across three health dimensions" />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demands), Timeline health (within end date)" />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
Shoring <InfoTooltip content="Offshore staffing ratio: percentage of hours from non-onshore resources. Color indicates threshold status." />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Score <InfoTooltip content="Composite score: average of Budget, Staffing, and Timeline health (0-100)" />
</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[160px] truncate">
<Link href={`/projects/${(row as any).id}`} className="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
</Link>
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
title={`Budget: ${row.budgetHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
title={`Staffing: ${row.staffingHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
title={`Timeline: ${row.timelineHealth}%`}
/>
</div>
</td>
<td className="px-3 py-2 text-center">
<ShoringBadge projectId={(row as any).id} />
</td>
<td className="px-3 py-2 text-right">
<span
className={`inline-block px-2 py-0.5 rounded-full font-semibold tabular-nums ${scoreBadge(row.compositeScore)}`}
>
{row.compositeScore}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -1,14 +1,29 @@
"use client";
import { useMemo } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
export function SkillGapWidget(_props: WidgetProps) {
const FILTERS: WidgetFilter[] = [
{ type: "search", key: "search", placeholder: "Search skill..." },
];
export function SkillGapWidget({ config, onConfigChange }: WidgetProps) {
const { data, isLoading } = trpc.dashboard.getSkillGaps.useQuery(
undefined,
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
const search = ((config.search as string) ?? "").toLowerCase();
const rows = useMemo(() => {
const all = data ?? [];
if (!search) return all;
return all.filter((r) => r.skill.toLowerCase().includes(search));
}, [data, search]);
if (isLoading && !data) {
return (
<div className="flex flex-col gap-1 pt-1">
@@ -24,71 +39,83 @@ export function SkillGapWidget(_props: WidgetProps) {
);
}
const rows = data ?? [];
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No skill gaps detected.
<div className="flex flex-col h-full">
<WidgetFilterBar filters={FILTERS} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="flex items-center justify-center flex-1 text-sm text-gray-400">
No skill gaps detected.
</div>
</div>
);
}
return (
<div className="overflow-auto h-full">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">Skill</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Demand</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Supply</th>
<th className="px-3 py-2 text-center font-medium text-gray-500">Gap</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => {
const isShortage = row.gap < 0;
const isSurplus = row.gap > 0;
return (
<tr key={row.skill} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900 max-w-[180px] truncate">
{row.skill}
</td>
<td className="px-3 py-2 text-right text-gray-700 tabular-nums">
{row.demand}
</td>
<td className="px-3 py-2 text-right text-gray-700 tabular-nums">
{row.supply}
</td>
<td className="px-3 py-2 text-center">
<span className="inline-flex items-center gap-1.5">
<span
className={`inline-block w-2 h-2 rounded-full ${
isShortage
? "bg-red-500"
: isSurplus
? "bg-green-500"
: "bg-gray-400"
}`}
/>
<span
className={`font-semibold tabular-nums ${
isShortage
? "text-red-700"
: isSurplus
? "text-green-700"
: "text-gray-500"
}`}
>
{row.gap > 0 ? `+${row.gap}` : row.gap}
<div className="flex flex-col h-full overflow-hidden">
<WidgetFilterBar filters={FILTERS} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="overflow-auto flex-1">
<table className="w-full text-xs">
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
Skill <InfoTooltip content="Skills required by open demand positions" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Demand <InfoTooltip content="Number of unfilled demand requirements needing this skill" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Supply <InfoTooltip content="Number of active resources with this skill at proficiency 3+" />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
Gap <InfoTooltip content="Supply minus Demand: negative (red) = shortage, positive (green) = surplus" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row) => {
const isShortage = row.gap < 0;
const isSurplus = row.gap > 0;
return (
<tr key={row.skill} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[180px] truncate">
{row.skill}
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
{row.demand}
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
{row.supply}
</td>
<td className="px-3 py-2 text-center">
<span className="inline-flex items-center gap-1.5">
<span
className={`inline-block w-2 h-2 rounded-full ${
isShortage
? "bg-red-500"
: isSurplus
? "bg-green-500"
: "bg-gray-400"
}`}
/>
<span
className={`font-semibold tabular-nums ${
isShortage
? "text-red-700"
: isSurplus
? "text-green-700"
: "text-gray-500"
}`}
>
{row.gap > 0 ? `+${row.gap}` : row.gap}
</span>
</span>
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
@@ -59,11 +59,13 @@ function StatCard({
</ProgressRing>
</div>
) : (
<span className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-50">
<AnimatedNumber value={value} suffix={suffix} />
</span>
<div className="mt-2">
<span className="text-2xl font-semibold text-gray-900 dark:text-gray-50">
<AnimatedNumber value={value} suffix={suffix} />
</span>
</div>
)}
{sub && <span className="mt-1 text-xs text-gray-500 dark:text-gray-400">{sub}</span>}
{sub && <p className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">{sub}</p>}
</div>
</FadeIn>
);
@@ -1,14 +1,24 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
type SortKey = "eid" | "name" | "chapter" | "score" | "lcr";
export function TopValueWidget({ config }: WidgetProps) {
export function TopValueWidget({ config, onConfigChange }: WidgetProps) {
const limit = (config.limit as number) || 10;
const { chapters } = useWidgetFilterOptions();
const filters = useMemo<WidgetFilter[]>(
() => [
{ type: "select", key: "chapter", label: "Chapter", options: chapters },
],
[chapters],
);
const [sortKey, setSortKey] = useState<SortKey>("score");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
@@ -23,6 +33,28 @@ export function TopValueWidget({ config }: WidgetProps) {
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
const chapter = (config.chapter as string) ?? "";
const list = useMemo(() => {
const all = (data ?? []) as Array<{ id: string; eid: string; displayName: string; chapter: string | null; lcrCents: number; valueScore: number | null }>;
if (!chapter) return all;
return all.filter((r) => r.chapter === chapter);
}, [data, chapter]);
const sorted = useMemo(() => {
return [...list].sort((a, b) => {
const mult = sortDir === "asc" ? 1 : -1;
switch (sortKey) {
case "eid": return mult * a.eid.localeCompare(b.eid);
case "name": return mult * a.displayName.localeCompare(b.displayName);
case "chapter": return mult * (a.chapter ?? "").localeCompare(b.chapter ?? "");
case "score": return mult * ((a.valueScore ?? 0) - (b.valueScore ?? 0));
case "lcr": return mult * (a.lcrCents - b.lcrCents);
default: return 0;
}
});
}, [list, sortKey, sortDir]);
if (isLoading) {
return (
<div className="flex flex-col gap-1 pt-1">
@@ -40,122 +72,114 @@ export function TopValueWidget({ config }: WidgetProps) {
);
}
const list = (data ?? []) as Array<{ id: string; eid: string; displayName: string; chapter: string | null; lcrCents: number; valueScore: number | null }>;
if (list.length === 0) {
if (sorted.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-center py-8 text-gray-400 text-sm">
<p>No scores computed yet or you lack access.</p>
<p className="text-xs mt-1">Admins can recompute scores in Settings.</p>
<div className="flex flex-col h-full">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="flex flex-col items-center justify-center flex-1 text-center py-8 text-gray-400 text-sm">
<p>No scores computed yet or you lack access.</p>
<p className="text-xs mt-1">Admins can recompute scores in Settings.</p>
</div>
</div>
);
}
const sorted = [...list].sort((a, b) => {
const mult = sortDir === "asc" ? 1 : -1;
switch (sortKey) {
case "eid": return mult * a.eid.localeCompare(b.eid);
case "name": return mult * a.displayName.localeCompare(b.displayName);
case "chapter": return mult * (a.chapter ?? "").localeCompare(b.chapter ?? "");
case "score": return mult * ((a.valueScore ?? 0) - (b.valueScore ?? 0));
case "lcr": return mult * (a.lcrCents - b.lcrCents);
default: return 0;
}
});
function Ind({ k }: { k: SortKey }) {
return sortKey === k
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "" : ""}</span>
: <span className="text-[10px] ml-0.5 text-gray-300"></span>;
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "\u25B2" : "\u25BC"}</span>
: <span className="text-[10px] ml-0.5 text-gray-300">{"\u21C5"}</span>;
}
return (
<div className="overflow-auto h-full">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 w-8">
<span className="inline-flex items-center">
#
<InfoTooltip content="Rank position based on the current sort order." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<span className="inline-flex items-center">
<button type="button" onClick={() => toggleSort("eid")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
EID<Ind k="eid" />
</button>
<InfoTooltip content="Employee ID — unique identifier for each resource." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<span className="inline-flex items-center">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Name<Ind k="name" />
</button>
<InfoTooltip content="Display name of the resource." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<span className="inline-flex items-center">
<button type="button" onClick={() => toggleSort("chapter")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Chapter<Ind k="chapter" />
</button>
<InfoTooltip content="Organizational chapter (team/department) the resource belongs to." />
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("score")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Score<Ind k="score" />
</button>
<InfoTooltip
content={
<span>
Composite price/quality score 0100.<br />
Weights: Skill Depth 30% · Cost Efficiency 25% · Skill Breadth 15% · Chargeability 15% · Experience 15%.<br />
Recompute in Admin Settings.
</span>
}
width="w-72"
/>
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("lcr")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
LCR ()<Ind k="lcr" />
</button>
<InfoTooltip content="Labour Cost Rate — hourly cost in EUR. Lower LCR = better cost efficiency score." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.map((r, i) => (
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-3 py-2 text-gray-400 font-medium">{i + 1}</td>
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td>
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "—"}</td>
<td className="px-3 py-2 text-right">
<span
className={`inline-block px-2 py-0.5 rounded-full font-semibold ${
(r.valueScore ?? 0) >= 70
? "bg-green-100 text-green-700"
: (r.valueScore ?? 0) >= 40
? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700"
}`}
>
{r.valueScore ?? "—"}
<div className="flex flex-col h-full overflow-hidden">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="overflow-auto flex-1">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 w-8">
<span className="inline-flex items-center">
#
<InfoTooltip content="Rank position based on the current sort order." />
</span>
</td>
<td className="px-3 py-2 text-right text-gray-700">{(r.lcrCents / 100).toFixed(0)}</td>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<span className="inline-flex items-center">
<button type="button" onClick={() => toggleSort("eid")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
EID<Ind k="eid" />
</button>
<InfoTooltip content="Employee ID — unique identifier for each resource." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<span className="inline-flex items-center">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Name<Ind k="name" />
</button>
<InfoTooltip content="Display name of the resource." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<span className="inline-flex items-center">
<button type="button" onClick={() => toggleSort("chapter")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Chapter<Ind k="chapter" />
</button>
<InfoTooltip content="Organizational chapter (team/department) the resource belongs to." />
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("score")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Score<Ind k="score" />
</button>
<InfoTooltip
content={
<span>
Composite price/quality score 0100.<br />
Weights: Skill Depth 30% · Cost Efficiency 25% · Skill Breadth 15% · Chargeability 15% · Experience 15%.<br />
Recompute in Admin Settings.
</span>
}
width="w-72"
/>
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("lcr")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
LCR ({"\u20AC"})<Ind k="lcr" />
</button>
<InfoTooltip content="Labour Cost Rate — hourly cost in EUR. Lower LCR = better cost efficiency score." />
</span>
</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.map((r, i) => (
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-3 py-2 text-gray-400 font-medium">{i + 1}</td>
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td>
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "\u2014"}</td>
<td className="px-3 py-2 text-right">
<span
className={`inline-block px-2 py-0.5 rounded-full font-semibold ${
(r.valueScore ?? 0) >= 70
? "bg-green-100 text-green-700"
: (r.valueScore ?? 0) >= 40
? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700"
}`}
>
{r.valueScore ?? "\u2014"}
</span>
</td>
<td className="px-3 py-2 text-right text-gray-700">{(r.lcrCents / 100).toFixed(0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -2,6 +2,7 @@
import { useState } from "react";
import { clsx } from "clsx";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { trpc } from "~/lib/trpc/client.js";
interface ApplyEffortRulesProps {
@@ -17,6 +18,7 @@ export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffort
const [selectedRuleSetId, setSelectedRuleSetId] = useState<string>("");
const [mode, setMode] = useState<"replace" | "append">("replace");
const [showPreview, setShowPreview] = useState(false);
const [confirmApply, setConfirmApply] = useState(false);
const previewQuery = trpc.effortRule.preview.useQuery(
{ estimateId, ruleSetId: selectedRuleSetId },
@@ -106,10 +108,7 @@ export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffort
<button
onClick={() => {
if (!selectedRuleSetId) return;
const action = mode === "replace" ? "replace all existing demand lines" : "append new demand lines";
if (confirm(`This will ${action}. Continue?`)) {
applyMutation.mutate({ estimateId, ruleSetId: selectedRuleSetId, mode });
}
setConfirmApply(true);
}}
disabled={!selectedRuleSetId || applyMutation.isPending}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
@@ -210,6 +209,19 @@ export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffort
{showPreview && previewQuery.isLoading && (
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
)}
{confirmApply && (
<ConfirmDialog
title="Apply effort rules"
message={`This will ${mode === "replace" ? "replace all existing demand lines" : "append new demand lines"}. Continue?`}
confirmLabel="Apply"
onConfirm={() => {
applyMutation.mutate({ estimateId, ruleSetId: selectedRuleSetId, mode });
setConfirmApply(false);
}}
onCancel={() => setConfirmApply(false)}
/>
)}
</div>
);
}
@@ -2,6 +2,7 @@
import { useState } from "react";
import { clsx } from "clsx";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatCents } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -18,6 +19,7 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A
const [selectedSetId, setSelectedSetId] = useState<string>("");
const [showPreview, setShowPreview] = useState(false);
const [confirmApply, setConfirmApply] = useState(false);
const previewQuery = trpc.experienceMultiplier.preview.useQuery(
{ estimateId, multiplierSetId: selectedSetId },
@@ -96,9 +98,7 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A
<button
onClick={() => {
if (!selectedSetId) return;
if (confirm("This will update cost/bill rates and hours on matching demand lines. Continue?")) {
applyMutation.mutate({ estimateId, multiplierSetId: selectedSetId });
}
setConfirmApply(true);
}}
disabled={!selectedSetId || applyMutation.isPending}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
@@ -204,6 +204,19 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A
{showPreview && previewQuery.isLoading && (
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
)}
{confirmApply && (
<ConfirmDialog
title="Apply experience multipliers"
message="This will update cost/bill rates and hours on matching demand lines. Continue?"
confirmLabel="Apply"
onConfirm={() => {
applyMutation.mutate({ estimateId, multiplierSetId: selectedSetId });
setConfirmApply(false);
}}
onCancel={() => setConfirmApply(false)}
/>
)}
</div>
);
}
@@ -441,7 +441,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Wizard</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Create a connected estimate</h2>
<p className="mt-1 text-sm text-gray-500">
Rates, resource snapshots, and project linkage are pulled from existing plANARCHY data.
Rates, resource snapshots, and project linkage are pulled from existing CapaKraken data.
</p>
</div>
<button
@@ -490,7 +490,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<input value={name} onChange={(event) => setName(event.target.value)} className={INPUT_CLS} placeholder="CGI Breakdown Q2 2026" />
</div>
<div>
<label className={LABEL_CLS}>Linked Project <InfoTooltip content="Link to an existing plANARCHY project. This enables automatic date-based phasing and planning handoff." /></label>
<label className={LABEL_CLS}>Linked Project <InfoTooltip content="Link to an existing CapaKraken project. This enables automatic date-based phasing and planning handoff." /></label>
<ProjectCombobox value={projectId} onChange={setProjectId} placeholder="Link to project" />
</div>
<div>
@@ -640,7 +640,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div key={line.id} className="rounded-3xl border border-gray-100 p-4">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<label className={LABEL_CLS}>Resource <InfoTooltip content="Link to a live plANARCHY resource. Auto-fills rates, chapter, and role." /></label>
<label className={LABEL_CLS}>Resource <InfoTooltip content="Link to a live CapaKraken resource. Auto-fills rates, chapter, and role." /></label>
<ResourceCombobox value={line.resourceId} onChange={(resourceId) => applyResource(resourceId, line.id)} placeholder="Search resource" />
</div>
<div>
@@ -336,7 +336,7 @@ export function DemandLineEditor({
<div className="mb-4 grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLS}>Linked resource <InfoTooltip content="Link to a plANARCHY resource. Live-linked rates refresh automatically; manual overrides are persisted." /></span>
<span className={LABEL_CLS}>Linked resource <InfoTooltip content="Link to a CapaKraken resource. Live-linked rates refresh automatically; manual overrides are persisted." /></span>
<select
className={INPUT_CLS}
value={line.resourceId ?? ""}
@@ -353,7 +353,7 @@ export function DemandLineEditor({
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Snapshot behavior</p>
<p className="mt-1 text-sm text-gray-700">
Linked resources refresh from live plANARCHY rates when a rate is set to live mode. Manual overrides are persisted on the demand line.
Linked resources refresh from live CapaKraken rates when a rate is set to live mode. Manual overrides are persisted on the demand line.
</p>
</div>
</div>
+56 -17
View File
@@ -76,9 +76,48 @@ function NotificationsIcon() {
function BroadcastIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg>;
}
function ActivityLogIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>;
}
function AdminIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 8a4 4 0 100 8 4 4 0 000-8zm8 4l-2.1.7a7.9 7.9 0 01-.6 1.5l1 2-2.1 2.1-2-1a7.9 7.9 0 01-1.5.6L12 20l-1.7-2.1a7.9 7.9 0 01-1.5-.6l-2 1-2.1-2.1 1-2a7.9 7.9 0 01-.6-1.5L4 12l2.1-1.7a7.9 7.9 0 01.6-1.5l-1-2 2.1-2.1 2 1a7.9 7.9 0 011.5-.6L12 4l1.7 2.1a7.9 7.9 0 011.5.6l2-1 2.1 2.1-1 2a7.9 7.9 0 01.6 1.5L20 12z" /></svg>;
}
function BlueprintIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>;
}
function ClientsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>;
}
function CountryIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>;
}
function OrgUnitIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" /></svg>;
}
function CategoryIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /></svg>;
}
function LevelsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 4h18M3 8h12M3 12h8M3 16h14M3 20h10" /></svg>;
}
function ImportIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>;
}
function CalcRulesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>;
}
function UsersIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>;
}
function SystemRolesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>;
}
function SettingsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>;
}
function WebhooksIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>;
}
function CollapseIcon({ collapsed }: { collapsed: boolean }) {
return (
@@ -144,8 +183,7 @@ const navSections: NavSection[] = [
{
label: "Analytics",
items: [
{ href: "/analytics/skills", label: "Skills Analytics", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/analytics/skill-marketplace", label: "Skill Marketplace", icon: <MarketplaceIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/skills", label: "Skills Hub", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/reports/builder", label: "Report Builder", icon: <ReportBuilderIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/computation-graph", label: "Computation Graph", icon: <GraphIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
@@ -170,25 +208,26 @@ function isSubGroup(entry: AdminEntry): entry is AdminSubGroup {
}
const adminNavEntries: AdminEntry[] = [
{ href: "/admin/blueprints", label: "Blueprints", icon: <AdminIcon /> },
{ href: "/admin/clients", label: "Clients", icon: <AdminIcon /> },
{ href: "/admin/blueprints", label: "Blueprints", icon: <BlueprintIcon /> },
{ href: "/admin/clients", label: "Clients", icon: <ClientsIcon /> },
{
label: "ACN-Orga",
collapsed: true,
items: [
{ href: "/admin/countries", label: "Countries", icon: <AdminIcon /> },
{ href: "/admin/org-units", label: "Org Units", icon: <AdminIcon /> },
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: <AdminIcon /> },
{ href: "/admin/management-levels", label: "Mgmt Levels", icon: <AdminIcon /> },
{ href: "/admin/countries", label: "Countries", icon: <CountryIcon /> },
{ href: "/admin/org-units", label: "Org Units", icon: <OrgUnitIcon /> },
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: <CategoryIcon /> },
{ href: "/admin/management-levels", label: "Mgmt Levels", icon: <LevelsIcon /> },
{ href: "/admin/imports", label: "Data Import", icon: <ImportIcon /> },
],
},
{ href: "/admin/calculation-rules", label: "Calc. Rules", icon: <AdminIcon /> },
{ href: "/admin/users", label: "Users", icon: <AdminIcon /> },
{ href: "/admin/system-roles", label: "System Roles", icon: <AdminIcon /> },
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
{ href: "/admin/calculation-rules", label: "Calc. Rules", icon: <CalcRulesIcon /> },
{ href: "/admin/users", label: "Users", icon: <UsersIcon /> },
{ href: "/admin/system-roles", label: "System Roles", icon: <SystemRolesIcon /> },
{ href: "/admin/settings", label: "Settings", icon: <SettingsIcon /> },
{ href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> },
{ href: "/admin/webhooks", label: "Webhooks", icon: <AdminIcon /> },
{ href: "/admin/webhooks", label: "Webhooks", icon: <WebhooksIcon /> },
{ href: "/admin/activity-log", label: "Activity Log", icon: <ActivityLogIcon /> },
];
/**
@@ -378,9 +417,9 @@ function SidebarContent({
{!sidebarCollapsed && (
<div className="overflow-hidden">
<h1 className="font-display text-xl font-semibold text-gray-900 dark:text-gray-50">
Pl<span className="text-brand-600">anarchy</span>
Capa<span className="text-brand-600">Kraken</span>
</h1>
<p className="text-xs uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">Resource Planning</p>
<p className="text-xs uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">Resource & Capacity Planning</p>
</div>
)}
</div>
@@ -849,7 +888,7 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
<HamburgerIcon />
</button>
<span className="ml-3 font-display text-sm font-semibold text-gray-900 dark:text-gray-50">
Pl<span className="text-brand-600">anarchy</span>
Capa<span className="text-brand-600">Kraken</span>
</span>
</div>
<PageTransition>{children}</PageTransition>
@@ -74,7 +74,7 @@ export function InstallPrompt() {
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-gray-50">
Install Planarchy
Install CapaKraken
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Add to home screen for quick access
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { trpc } from "~/lib/trpc/client.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { TaskCard } from "./TaskCard.js";
@@ -31,6 +32,7 @@ export function NotificationCenterClient() {
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
const { canEdit } = usePermissions();
const [showTaskModal, setShowTaskModal] = useState(false);
const [confirmDeleteReminder, setConfirmDeleteReminder] = useState<string | null>(null);
const [reminderModal, setReminderModal] = useState<{
open: boolean;
reminder: {
@@ -374,11 +376,7 @@ export function NotificationCenterClient() {
</button>
<button
type="button"
onClick={() => {
if (window.confirm("Delete this reminder?")) {
deleteReminder.mutate({ id: r.id });
}
}}
onClick={() => setConfirmDeleteReminder(r.id)}
disabled={deleteReminder.isPending}
className="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
title="Delete"
@@ -413,6 +411,20 @@ export function NotificationCenterClient() {
onSuccess={() => setShowTaskModal(false)}
/>
)}
{confirmDeleteReminder && (
<ConfirmDialog
title="Delete reminder"
message="Are you sure you want to delete this reminder?"
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteReminder.mutate({ id: confirmDeleteReminder });
setConfirmDeleteReminder(null);
}}
onCancel={() => setConfirmDeleteReminder(null)}
/>
)}
</div>
);
}
@@ -1,9 +1,10 @@
"use client";
import { useRef, useState } from "react";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
const RECURRENCE_OPTIONS = [
{ value: "", label: "None" },
@@ -50,6 +51,7 @@ export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalPro
const [recurrence, setRecurrence] = useState(reminder?.recurrence ?? "");
const [link, setLink] = useState(reminder?.link ?? "");
const [serverError, setServerError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
@@ -128,8 +130,7 @@ export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalPro
function handleDelete() {
if (!reminder) return;
if (!window.confirm("Delete this reminder?")) return;
deleteMutation.mutate({ id: reminder.id });
setConfirmDelete(true);
}
const inputClass =
@@ -303,6 +304,20 @@ export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalPro
</div>
</form>
</div>
{confirmDelete && reminder && (
<ConfirmDialog
title="Delete reminder"
message="Are you sure you want to delete this reminder?"
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate({ id: reminder.id });
setConfirmDelete(false);
}}
onCancel={() => setConfirmDelete(false)}
/>
)}
</div>
);
}
@@ -25,7 +25,7 @@ export function CoverArtSection({ projectId, coverImageUrl, coverFocusY = 50, pr
const fileInputRef = useRef<HTMLInputElement>(null);
const utils = trpc.useUtils();
const { data: dalleStatus } = trpc.project.isDalleConfigured.useQuery();
const { data: imageGenStatus } = trpc.project.isImageGenConfigured.useQuery();
const generateMutation = trpc.project.generateCover.useMutation();
const uploadMutation = trpc.project.uploadCover.useMutation();
const removeMutation = trpc.project.removeCover.useMutation();
@@ -207,7 +207,7 @@ export function CoverArtSection({ projectId, coverImageUrl, coverFocusY = 50, pr
)}
{/* Generate with AI */}
{dalleStatus?.configured && (
{imageGenStatus?.configured && (
<button
type="button"
onClick={() => {
@@ -50,6 +50,7 @@ interface FormState {
color: string;
utilizationCategoryId: string;
clientId: string;
shoringThreshold: string;
}
function getDefaultForm(): FormState {
@@ -68,6 +69,7 @@ function getDefaultForm(): FormState {
color: "",
utilizationCategoryId: "",
clientId: "",
shoringThreshold: "55",
};
}
@@ -86,6 +88,7 @@ function projectToForm(project: Project): FormState {
color: (project as unknown as { color?: string | null }).color ?? "",
utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
clientId: (project as unknown as { clientId?: string | null }).clientId ?? "",
shoringThreshold: String((project as unknown as { shoringThreshold?: number | null }).shoringThreshold ?? 55),
};
}
@@ -204,10 +207,11 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
startDate: new Date(form.startDate),
endDate: new Date(form.endDate),
status: form.status as unknown as ProjectStatus,
responsiblePerson: form.responsiblePerson.trim() || undefined,
responsiblePerson: form.responsiblePerson.trim(),
...(form.color ? { color: form.color } : {}),
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
...(form.clientId ? { clientId: form.clientId } : {}),
shoringThreshold: Number(form.shoringThreshold),
},
});
} else {
@@ -223,10 +227,11 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
status: form.status as unknown as ProjectStatus,
staffingReqs: [],
dynamicFields: {},
responsiblePerson: form.responsiblePerson.trim() || undefined,
responsiblePerson: form.responsiblePerson.trim(),
...(form.color ? { color: form.color } : {}),
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
...(form.clientId ? { clientId: form.clientId } : {}),
shoringThreshold: Number(form.shoringThreshold),
});
}
}
@@ -441,7 +446,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Timeline &amp; Budget
</legend>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-4 gap-4">
<div>
<label className={labelClass} htmlFor="startDate">
Start Date
@@ -492,6 +497,22 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.budgetEur}</p>
)}
</div>
<div>
<label className={labelClass} htmlFor="shoringThreshold">
Min Offshore %
<InfoTooltip content="Minimum offshore staffing target (0-100). Green when met, red when below. Higher offshore = more cost-efficient. Default: 55%." />
</label>
<input
id="shoringThreshold"
type="number"
min={0}
max={100}
value={form.shoringThreshold}
onChange={(e) => setField("shoringThreshold", e.target.value)}
placeholder="55"
className={inputClass}
/>
</div>
</div>
</fieldset>
@@ -521,8 +542,8 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
</div>
<div>
<label className={labelClass} htmlFor="responsiblePerson">
Responsible Person
<InfoTooltip content="Project lead or account manager responsible for this project." />
Responsible Person <span className="text-red-500">*</span>
<InfoTooltip content="Project lead or account manager responsible for this project. Required." />
</label>
<input
id="responsiblePerson"
@@ -530,6 +551,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
value={form.responsiblePerson}
onChange={(e) => setField("responsiblePerson", e.target.value)}
placeholder="Name or EID"
required
className={inputClass}
/>
</div>
@@ -1089,7 +1089,7 @@ export function ProjectWizard({ open, onClose }: ProjectWizardProps) {
endDate: new Date(state.endDate),
staffingReqs: state.staffingReqs,
status: state.saveAsDraft ? ProjectStatus.DRAFT : ProjectStatus.ACTIVE,
responsiblePerson: state.responsiblePerson.trim() || undefined,
responsiblePerson: state.responsiblePerson.trim(),
blueprintId: state.blueprintId ?? undefined,
dynamicFields: {},
});
@@ -0,0 +1,200 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
// Stable country colors — deterministic from code
const COUNTRY_COLORS: Record<string, string> = {
DE: "#3b82f6",
ES: "#f59e0b",
IN: "#10b981",
PL: "#ef4444",
PT: "#8b5cf6",
RO: "#ec4899",
CZ: "#06b6d4",
HU: "#f97316",
BG: "#14b8a6",
US: "#6366f1",
UK: "#a855f7",
FR: "#84cc16",
IT: "#e11d48",
UNKNOWN: "#9ca3af",
};
function getCountryColor(code: string): string {
if (COUNTRY_COLORS[code]) return COUNTRY_COLORS[code];
// Deterministic fallback based on char codes
let hash = 0;
for (let i = 0; i < code.length; i++) {
hash = code.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 65%, 50%)`;
}
function getSeverity(offshoreRatio: number, threshold: number): "green" | "yellow" | "red" {
// Higher offshore = better (cost-efficient). Threshold is the MINIMUM target.
if (offshoreRatio >= threshold) return "green"; // Target met
if (offshoreRatio >= threshold - 10) return "yellow"; // Close to target
return "red"; // Too little offshore
}
const SEVERITY_BADGE: Record<string, string> = {
green: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
yellow: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300",
red: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
};
const SEVERITY_DOT: Record<string, string> = {
green: "bg-green-500",
yellow: "bg-yellow-500",
red: "bg-red-500",
};
// ─── Mini badge for list views ────────────────────────────────────────────────
export function ShoringBadge({ projectId }: { projectId: string }) {
const { data, isLoading } = trpc.project.getShoringRatio.useQuery(
{ projectId },
{ staleTime: 60_000 },
);
if (isLoading) {
return <span className="inline-block h-4 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />;
}
if (!data || data.totalHours === 0) {
return <span className="text-xs text-gray-400 dark:text-gray-500">--</span>;
}
const severity = getSeverity(data.offshoreRatio, data.threshold);
return (
<span className="inline-flex items-center gap-1.5">
<span className={`h-2 w-2 rounded-full ${SEVERITY_DOT[severity]}`} />
<span className="text-xs text-gray-700 dark:text-gray-300">{data.offshoreRatio}%</span>
</span>
);
}
// ─── Full indicator for detail views ──────────────────────────────────────────
export function ShoringIndicator({ projectId }: { projectId: string }) {
const [tooltipOpen, setTooltipOpen] = useState(false);
const { data, isLoading } = trpc.project.getShoringRatio.useQuery(
{ projectId },
{ staleTime: 30_000 },
);
if (isLoading) {
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<div className="space-y-3 animate-pulse">
<div className="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-6 w-full rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-48 rounded bg-gray-200 dark:bg-gray-700" />
</div>
</div>
);
}
if (!data || data.totalHours === 0) {
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
Nearshore Ratio
</h3>
<p className="text-sm text-gray-400 dark:text-gray-500">No assignments</p>
</div>
);
}
const severity = getSeverity(data.offshoreRatio, data.threshold);
// Build sorted country segments for the bar
const segments = Object.entries(data.byCountry)
.filter(([code]) => code !== "UNKNOWN")
.sort((a, b) => b[1].pct - a[1].pct);
if (data.byCountry["UNKNOWN"]) {
segments.push(["UNKNOWN", data.byCountry["UNKNOWN"]]);
}
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Nearshore Ratio
</h3>
<span className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${SEVERITY_BADGE[severity]}`}>
{data.offshoreRatio}% offshore
{severity === "green" ? " — Target met" : severity === "red" ? ` — Below ${data.threshold}% target` : ""}
</span>
</div>
{/* Stacked horizontal bar */}
<div
className="relative h-7 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800 cursor-pointer"
onMouseEnter={() => setTooltipOpen(true)}
onMouseLeave={() => setTooltipOpen(false)}
>
<div className="absolute inset-0 flex">
{segments.map(([code, info]) => (
<div
key={code}
className="h-full flex items-center justify-center text-[10px] font-semibold text-white transition-all duration-500 first:rounded-l-full last:rounded-r-full"
style={{
width: `${info.pct}%`,
backgroundColor: getCountryColor(code),
minWidth: info.pct > 0 ? "2px" : "0",
}}
>
{info.pct > 10 ? `${code} ${info.pct}%` : ""}
</div>
))}
</div>
{/* Tooltip overlay */}
{tooltipOpen && (
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-10 min-w-[200px] rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-3 shadow-xl text-xs">
<div className="font-semibold text-gray-900 dark:text-gray-100 mb-2">
Country Breakdown
</div>
<div className="space-y-1.5">
{segments.map(([code, info]) => (
<div key={code} className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span
className="inline-block h-2.5 w-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: getCountryColor(code) }}
/>
<span className="text-gray-700 dark:text-gray-300">
{code === "UNKNOWN" ? "Unknown" : code}
</span>
</div>
<div className="text-gray-500 dark:text-gray-400 tabular-nums">
{info.pct}% ({info.resourceCount} {info.resourceCount === 1 ? "person" : "people"})
</div>
</div>
))}
</div>
{data.unknownCount > 0 && (
<div className="mt-2 pt-2 border-t border-gray-100 dark:border-gray-800 text-gray-400 dark:text-gray-500">
{data.unknownCount} resource{data.unknownCount !== 1 ? "s" : ""} without country
</div>
)}
</div>
)}
</div>
{/* Summary text */}
<div className="mt-2 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<span>{data.onshoreRatio}% onshore ({data.onshoreCountryCode})</span>
<span className="text-gray-300 dark:text-gray-600">|</span>
<span>{data.offshoreRatio}% offshore</span>
<span className="text-gray-300 dark:text-gray-600">|</span>
<span>Threshold: {data.threshold}%</span>
</div>
</div>
);
}
@@ -63,7 +63,7 @@ export function AllocationReport({ title, generatedAt, rows }: AllocationReportP
))}
</View>
<Text style={styles.footer}>plANARCHY · Confidential · {rows.length} allocations</Text>
<Text style={styles.footer}>CapaKraken · Confidential · {rows.length} allocations</Text>
</Page>
</Document>
);
@@ -169,7 +169,7 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
{preview.matchedRoleName && (
<p className="text-xs text-gray-600">
<span className="font-medium">Area of expertise</span> matched to plANARCHY role:{" "}
<span className="font-medium">Area of expertise</span> matched to CapaKraken role:{" "}
<span className="font-semibold text-brand-700">{preview.matchedRoleName}</span>
</p>
)}
+4 -20
View File
@@ -1,9 +1,9 @@
"use client";
import { useRef, useState } from "react";
import { useState } from "react";
import type { RoleWithResourceCount } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const PRESET_COLORS = [
@@ -33,9 +33,6 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
const [color, setColor] = useState(role?.color ?? PRESET_COLORS[0]!);
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils();
const createMutation = trpc.role.create.useMutation({
@@ -82,19 +79,7 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
const labelClass = "app-label";
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/55 py-8 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="mx-4 w-full max-w-md rounded-3xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900"
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
>
<AnimatedModal open onClose={onClose} maxWidth="max-w-md">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isEditing ? "Edit Role" : "New Role"}
@@ -199,7 +184,6 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
</button>
</div>
</form>
</div>
</div>
</AnimatedModal>
);
}
@@ -75,7 +75,7 @@ export function StaffingPanel() {
<div className="app-surface max-w-xl p-4">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">How scoring works</p>
<p className="mt-1 text-sm text-gray-500">
plANARCHY blends skill fit, free capacity, cost, and current utilization. Add the must-have skills first, then narrow the date window to get cleaner results.
CapaKraken blends skill fit, free capacity, cost, and current utilization. Add the must-have skills first, then narrow the date window to get cleaner results.
</p>
</div>
</div>
@@ -137,47 +137,6 @@ type ProjectFlatRow =
const EMPTY_DAY_METRICS: ProjectDayMetric[] = [];
const SVG_XMLNS = "http://www.w3.org/2000/svg";
function buildProjectRowGridBackground(dates: Date[], CELL_WIDTH: number, today: Date) {
const gradientLayers: string[] = [
`repeating-linear-gradient(to right, transparent 0, transparent ${Math.max(
CELL_WIDTH - 1,
0,
)}px, rgba(229, 231, 235, 1) ${Math.max(CELL_WIDTH - 1, 0)}px, rgba(229, 231, 235, 1) ${CELL_WIDTH}px)`,
];
dates.forEach((date, index) => {
const left = index * CELL_WIDTH;
const right = left + CELL_WIDTH;
const isToday = date.toDateString() === today.toDateString();
const isSaturday = date.getDay() === 6;
const isSunday = date.getDay() === 0;
if (isSaturday) {
gradientLayers.push(
`linear-gradient(to right, transparent ${left}px, rgba(254, 243, 199, 0.4) ${left}px, rgba(254, 243, 199, 0.4) ${right}px, transparent ${right}px)`,
);
} else if (isSunday) {
gradientLayers.push(
`linear-gradient(to right, transparent ${left}px, rgba(243, 244, 246, 0.6) ${left}px, rgba(243, 244, 246, 0.6) ${right}px, transparent ${right}px)`,
);
}
if (isToday) {
gradientLayers.push(
`linear-gradient(to right, transparent ${left}px, rgba(110, 231, 183, 0.95) ${left}px, rgba(110, 231, 183, 0.95) ${Math.min(
left + 2,
right,
)}px, transparent ${Math.min(left + 2, right)}px)`,
);
}
});
return {
backgroundImage: gradientLayers.join(", "),
backgroundRepeat: "no-repeat",
} as const;
}
// ─── Component ──────────────────────────────────────────────────────────────
function TimelineProjectPanelInner({
@@ -432,11 +391,6 @@ function TimelineProjectPanelInner({
const virtualItems = rowVirtualizer.getVirtualItems();
const totalRowHeight = rowVirtualizer.getTotalSize();
const resourceRowGridStyle = useMemo(
() => buildProjectRowGridBackground(dates, CELL_WIDTH, today),
[CELL_WIDTH, dates, today],
);
const resourcesWithVacations = useMemo(() => {
const result = new Set<string>();
for (const [resourceId, vacations] of vacationsByResource) {
@@ -713,7 +667,7 @@ function TimelineProjectPanelInner({
totalCanvasWidth,
toLeft,
toWidth,
resourceRowGridStyle,
gridLines,
onOpenDemandClick,
onAllocMouseDown,
onAllocTouchStart,
@@ -750,7 +704,6 @@ function TimelineProjectPanelInner({
width: totalCanvasWidth,
height: ROW_HEIGHT,
touchAction: "none",
...resourceRowGridStyle,
}}
onMouseDown={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
@@ -776,6 +729,7 @@ function TimelineProjectPanelInner({
}}
onMouseLeave={clearHoverTooltips}
>
{gridLines}
{renderProjectUtilOverlay(
projectRowMetrics.get(row.metricsKey) ?? EMPTY_DAY_METRICS,
CELL_WIDTH,
@@ -889,7 +843,7 @@ function renderOpenDemandRow(
totalCanvasWidth: number,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
rowGridStyle: CSSProperties,
rowGridLines: React.ReactNode,
_onOpenDemandClick: (demand: OpenDemandAssignment) => void,
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
@@ -934,8 +888,9 @@ function renderOpenDemandRow(
<div
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
style={{ width: totalCanvasWidth, height: rowHeight, ...rowGridStyle }}
style={{ width: totalCanvasWidth, height: rowHeight }}
>
{rowGridLines}
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
<div className="pointer-events-none absolute inset-x-0 inset-y-1 rounded-md bg-amber-100/25 dark:bg-amber-950/35" />
{openDemands.map((alloc) => {
@@ -141,6 +141,10 @@ function TimelineResourcePanelInner({
orderType: string;
hoursPerDay: number;
responsiblePerson?: string | null;
role?: string | null;
status?: string;
startDate?: string;
endDate?: string;
}[];
} | null>(null);
@@ -295,6 +299,10 @@ function TimelineResourcePanelInner({
orderType: string;
hours: number;
responsiblePerson?: string | null;
role?: string | null;
status?: string;
startDate?: string;
endDate?: string;
}
>();
for (const alloc of a) {
@@ -314,6 +322,10 @@ function TimelineResourcePanelInner({
hours: alloc.hoursPerDay,
responsiblePerson:
(alloc.project as { responsiblePerson?: string | null }).responsiblePerson ?? null,
role: alloc.role ?? alloc.roleEntity?.name ?? null,
status: alloc.status,
startDate: new Date(alloc.startDate).toISOString().slice(0, 10),
endDate: new Date(alloc.endDate).toISOString().slice(0, 10),
});
}
}
@@ -90,7 +90,7 @@ export function TimelineToolbar({
}
return (
<div className="app-toolbar flex flex-wrap items-center justify-between gap-3">
<div className="app-toolbar relative z-20 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<ProjectCombobox
value={filters.projectIds[0] ?? null}
@@ -13,6 +13,10 @@ export type HeatmapHoverData = {
orderType: string;
hoursPerDay: number;
responsiblePerson?: string | null;
role?: string | null;
status?: string;
startDate?: string;
endDate?: string;
}[];
};
@@ -81,10 +85,20 @@ export function TimelineTooltip({
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{entry.responsiblePerson
? `Lead: ${entry.responsiblePerson}`
: entry.orderType}
{[
entry.role,
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
entry.orderType,
].filter(Boolean).join(" · ")}
</div>
{entry.startDate && entry.endDate && (
<div className="text-[10px] text-gray-500">
{entry.startDate} {entry.endDate}
{entry.status && entry.status !== "CONFIRMED" && (
<span className="ml-1 uppercase text-amber-400">{entry.status}</span>
)}
</div>
)}
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
@@ -146,10 +160,20 @@ export function TimelineTooltip({
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{entry.responsiblePerson
? `Lead: ${entry.responsiblePerson}`
: entry.orderType}
{[
entry.role,
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
entry.orderType,
].filter(Boolean).join(" · ")}
</div>
{entry.startDate && entry.endDate && (
<div className="text-[10px] text-gray-500">
{entry.startDate} {entry.endDate}
{entry.status && entry.status !== "CONFIRMED" && (
<span className="ml-1 uppercase text-amber-400">{entry.status}</span>
)}
</div>
)}
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
@@ -615,7 +615,7 @@ function TimelineViewContent({
<div
ref={scrollContainerRef}
onScroll={handleContainerScroll}
className="app-surface relative flex-1 overflow-auto"
className="app-surface relative z-0 flex-1 overflow-auto"
>
{isInitialLoading ? (
<div className="flex items-center justify-center py-24 text-sm text-gray-500 dark:text-gray-400">
@@ -0,0 +1,136 @@
"use client";
import { useState, useRef, useEffect, useMemo, type ReactNode } from "react";
import { useDebounce } from "~/hooks/useDebounce.js";
interface EntityComboboxProps<T extends { id: string }> {
value: string | null;
onChange: (id: string | null) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
/** Hook that returns search results when the dropdown is open. */
useSearchQuery: (search: string, enabled: boolean) => { data: T[] | undefined };
/** Hook that returns a broader list so the selected item's label can be resolved when the dropdown is closed. */
useSelectedQuery: (id: string | null, enabled: boolean) => { data: T[] | undefined };
/** Derive the display label from an item (shown in the input when closed). */
getLabel: (item: T) => string;
/** Optional custom renderer for each dropdown row. Falls back to `getLabel`. */
renderItem?: (item: T, isSelected: boolean) => ReactNode;
}
export function EntityCombobox<T extends { id: string }>({
value,
onChange,
placeholder = "Search\u2026",
disabled = false,
className = "",
useSearchQuery,
useSelectedQuery,
getLabel,
renderItem,
}: EntityComboboxProps<T>) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { data: searchItems } = useSearchQuery(debouncedSearch, open);
const items = searchItems ?? [];
const { data: selectedItems } = useSelectedQuery(value, !!value && !open);
const selectedLabel = useMemo(() => {
if (!value) return "";
const fromOpen = items.find((i) => i.id === value);
if (fromOpen) return getLabel(fromOpen);
const fromSelected = selectedItems?.find((i) => i.id === value);
if (fromSelected) return getLabel(fromSelected);
return value;
}, [value, items, selectedItems, getLabel]);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleFocus() {
if (disabled) return;
setOpen(true);
setSearch("");
}
function select(id: string | null) {
onChange(id);
setOpen(false);
setSearch("");
inputRef.current?.blur();
}
return (
<div className={`relative ${className}`} ref={containerRef}>
<div className="relative">
<input
ref={inputRef}
type="text"
value={open ? search : selectedLabel}
onChange={(e) => setSearch(e.target.value)}
onFocus={handleFocus}
placeholder={placeholder}
disabled={disabled}
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed ${
open
? "border-brand-500 ring-2 ring-brand-500"
: "border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500"
} bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500`}
readOnly={!open}
/>
{value && !disabled && !open && (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); select(null); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none"
aria-label="Clear"
tabIndex={-1}
>
{"\u00d7"}
</button>
)}
</div>
{open && (
<div className="absolute left-0 right-0 top-full mt-1 z-[60] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden">
<ul className="max-h-52 overflow-y-auto py-1">
{items.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-400 dark:text-gray-500">No results</li>
) : (
items.map((item) => (
<li key={item.id}>
<button
type="button"
onMouseDown={() => select(item.id)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 dark:hover:bg-brand-950/40 ${
item.id === value
? "bg-brand-50 dark:bg-brand-950/40 text-brand-700 dark:text-brand-300 font-medium"
: "text-gray-700 dark:text-gray-200"
}`}
>
{renderItem ? renderItem(item, item.id === value) : getLabel(item)}
</button>
</li>
))
)}
</ul>
</div>
)}
</div>
);
}
@@ -0,0 +1,176 @@
"use client";
import { useState, useCallback } from "react";
import Link from "next/link";
import type { Route } from "next";
import { trpc } from "~/lib/trpc/client.js";
// ─── Helpers ────────────────────────────────────────────────────────────────
const ACTION_BADGES: Record<string, { label: string; className: string }> = {
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" },
};
function relativeTime(date: Date | string): 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 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);
}
type DiffEntry = { old: unknown; new: unknown };
type Changes = {
before?: Record<string, unknown>;
after?: Record<string, unknown>;
diff?: Record<string, DiffEntry>;
};
function parseChanges(changes: unknown): Changes {
if (!changes || typeof changes !== "object") return {};
return changes as Changes;
}
// ─── Component ──────────────────────────────────────────────────────────────
interface EntityHistoryProps {
entityType: string;
entityId: string;
limit?: number;
}
type AuditEntry = {
id: string;
entityType: string;
entityId: string;
action: string;
changes: unknown;
createdAt: Date | string;
source: string | null;
entityName: string | null;
summary: string | null;
user: { id: string; name: string | null; email: string } | null;
};
export function EntityHistory({ entityType, entityId, limit = 10 }: EntityHistoryProps) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const { data: entries = [], isLoading } = trpc.auditLog.getByEntity.useQuery(
{ entityType, entityId, limit },
{ staleTime: 30_000 },
) as { data: AuditEntry[]; isLoading: boolean };
const toggleExpand = useCallback((id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center py-6">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
</div>
);
}
if (entries.length === 0) {
return (
<div className="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
No history recorded yet.
</div>
);
}
return (
<div className="space-y-1">
<h3 className="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Change History</h3>
{/* Timeline */}
<div className="relative space-y-0">
{/* Vertical line */}
<div className="absolute left-3 top-2 bottom-2 w-px bg-gray-200 dark:bg-slate-600" />
{entries.map((entry) => {
const changes = parseChanges(entry.changes);
const isExpanded = expandedId === entry.id;
const badge = ACTION_BADGES[entry.action] ?? { label: entry.action, className: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" };
return (
<div key={entry.id} className="relative pl-8">
{/* Dot */}
<div className="absolute left-1.5 top-2.5 h-3 w-3 rounded-full border-2 border-white bg-gray-400 dark:border-slate-800 dark:bg-slate-500" />
<button
onClick={() => toggleExpand(entry.id)}
className="w-full rounded-lg p-2 text-left transition-colors hover:bg-gray-50 dark:hover:bg-slate-700/50"
>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}>
{badge.label}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{entry.user?.name ?? entry.user?.email ?? "System"}
</span>
<span
className="ml-auto text-[10px] text-gray-400 dark:text-gray-500"
title={new Date(entry.createdAt).toLocaleString("de-DE")}
>
{relativeTime(entry.createdAt)}
</span>
</div>
{entry.summary && (
<p className="mt-0.5 text-xs text-gray-600 dark:text-gray-400">{entry.summary}</p>
)}
</button>
{/* Expanded diff */}
{isExpanded && changes.diff && Object.keys(changes.diff).length > 0 && (
<div className="mb-2 ml-2 rounded-lg border border-gray-100 bg-gray-50 p-2 dark:border-slate-700 dark:bg-slate-800/50">
{Object.entries(changes.diff).map(([field, { old: oldVal, new: newVal }]) => (
<div key={field} className="flex items-start gap-2 text-xs">
<span className="min-w-[80px] shrink-0 font-medium text-gray-600 dark:text-gray-400">{field}</span>
<span className="rounded bg-red-50 px-1 text-red-600 line-through dark:bg-red-900/20 dark:text-red-400">
{formatValue(oldVal)}
</span>
<span className="text-gray-400">&rarr;</span>
<span className="rounded bg-emerald-50 px-1 text-emerald-600 dark:bg-emerald-900/20 dark:text-emerald-400">
{formatValue(newVal)}
</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
{/* Link to full log */}
<div className="pt-2 text-center">
<Link
href={`/admin/activity-log?entityType=${encodeURIComponent(entityType)}&search=${encodeURIComponent(entityId)}` as Route}
className="text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
>
View all in Activity Log
</Link>
</div>
</div>
);
}
+39 -111
View File
@@ -1,9 +1,11 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import type { ProjectStatus } from "@planarchy/shared";
import { EntityCombobox } from "./EntityCombobox.js";
type ProjectItem = { id: string; shortCode: string; name: string };
interface ProjectComboboxProps {
value: string | null;
@@ -15,122 +17,48 @@ interface ProjectComboboxProps {
}
export function ProjectCombobox({
value,
onChange,
placeholder = "Search project\u2026",
disabled = false,
status,
className = "",
...props
}: ProjectComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const useSearchQuery = (search: string, enabled: boolean) => {
const { data } = trpc.project.list.useQuery(
{ search: search || undefined, limit: 15, ...(status ? { status } : {}) },
{ enabled, staleTime: 30_000 },
);
return { data: (data?.projects ?? []) as ProjectItem[] };
};
const { data } = trpc.project.list.useQuery(
{ search: debouncedSearch || undefined, limit: 15, ...(status ? { status } : {}) },
{ enabled: open, staleTime: 30_000 },
const useSelectedQuery = (_id: string | null, enabled: boolean) => {
const { data } = trpc.project.list.useQuery(
{ limit: 500 },
{ enabled, staleTime: 60_000 },
);
return { data: (data?.projects ?? []) as ProjectItem[] };
};
const getLabel = useCallback(
(p: ProjectItem) => `${p.shortCode} \u2014 ${p.name}`,
[],
);
const projects = data?.projects ?? [];
const { data: allData } = trpc.project.list.useQuery(
{ limit: 500 },
{ enabled: !!value && !open, staleTime: 60_000 },
const renderItem = useCallback(
(p: ProjectItem) => (
<>
<span className="font-medium text-xs text-gray-400 dark:text-gray-500 mr-1.5">{p.shortCode}</span>
<span>{p.name}</span>
</>
),
[],
);
const selectedLabel = useMemo(() => {
if (!value) return "";
const fromOpen = projects.find((p) => p.id === value);
if (fromOpen) return `${fromOpen.shortCode} \u2014 ${fromOpen.name}`;
const fromAll = allData?.projects.find((p) => p.id === value);
if (fromAll) return `${fromAll.shortCode} \u2014 ${fromAll.name}`;
return value;
}, [value, projects, allData]);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleFocus() {
if (disabled) return;
setOpen(true);
setSearch("");
}
function select(id: string | null) {
onChange(id);
setOpen(false);
setSearch("");
inputRef.current?.blur();
}
return (
<div className={`relative ${className}`} ref={containerRef}>
<div className="relative">
<input
ref={inputRef}
type="text"
value={open ? search : selectedLabel}
onChange={(e) => setSearch(e.target.value)}
onFocus={handleFocus}
placeholder={placeholder}
disabled={disabled}
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed ${
open
? "border-brand-500 ring-2 ring-brand-500"
: "border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500"
} bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500`}
readOnly={!open}
/>
{value && !disabled && !open && (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); select(null); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none"
aria-label="Clear"
tabIndex={-1}
>
\u00d7
</button>
)}
</div>
{open && (
<div className="absolute left-0 right-0 top-full mt-1 z-[60] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden">
<ul className="max-h-52 overflow-y-auto py-1">
{projects.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-400 dark:text-gray-500">No results</li>
) : (
projects.map((p) => (
<li key={p.id}>
<button
type="button"
onMouseDown={() => select(p.id)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 dark:hover:bg-brand-950/40 ${
p.id === value
? "bg-brand-50 dark:bg-brand-950/40 text-brand-700 dark:text-brand-300 font-medium"
: "text-gray-700 dark:text-gray-200"
}`}
>
<span className="font-medium text-xs text-gray-400 dark:text-gray-500 mr-1.5">{p.shortCode}</span>
<span>{p.name}</span>
</button>
</li>
))
)}
</ul>
</div>
)}
</div>
<EntityCombobox<ProjectItem>
{...props}
placeholder={props.placeholder ?? "Search project\u2026"}
useSearchQuery={useSearchQuery}
useSelectedQuery={useSelectedQuery}
getLabel={getLabel}
renderItem={renderItem}
/>
);
}
+39 -112
View File
@@ -1,8 +1,10 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { EntityCombobox } from "./EntityCombobox.js";
type ResourceItem = { id: string; displayName: string; eid: string };
interface ResourceComboboxProps {
value: string | null;
@@ -14,123 +16,48 @@ interface ResourceComboboxProps {
}
export function ResourceCombobox({
value,
onChange,
placeholder = "Search resource\u2026",
disabled = false,
isActive = true,
className = "",
...props
}: ResourceComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const useSearchQuery = (search: string, enabled: boolean) => {
const { data } = trpc.resource.list.useQuery(
{ search: search || undefined, limit: 15, isActive },
{ enabled, staleTime: 30_000 },
);
return { data: (data?.resources ?? []) as ResourceItem[] };
};
const { data } = trpc.resource.list.useQuery(
{ search: debouncedSearch || undefined, limit: 15, isActive },
{ enabled: open, staleTime: 30_000 },
const useSelectedQuery = (_id: string | null, enabled: boolean) => {
const { data } = trpc.resource.list.useQuery(
{ limit: 500 },
{ enabled, staleTime: 60_000 },
);
return { data: (data?.resources ?? []) as ResourceItem[] };
};
const getLabel = useCallback(
(r: ResourceItem) => `${r.displayName} (${r.eid})`,
[],
);
const resources = (data?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
const selectedQuery = trpc.resource.list.useQuery(
{ limit: 500 },
{ enabled: !!value && !open, staleTime: 60_000 },
const renderItem = useCallback(
(r: ResourceItem) => (
<>
<span>{r.displayName}</span>
<span className="ml-1.5 text-xs text-gray-400 dark:text-gray-500">{r.eid}</span>
</>
),
[],
);
const selectedResources = (selectedQuery.data?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
const selectedLabel = useMemo(() => {
if (!value) return "";
const fromOpen = resources.find((r) => r.id === value);
if (fromOpen) return `${fromOpen.displayName} (${fromOpen.eid})`;
const fromSelected = selectedResources.find((r) => r.id === value);
if (fromSelected) return `${fromSelected.displayName} (${fromSelected.eid})`;
return value;
}, [value, resources, selectedResources]);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleFocus() {
if (disabled) return;
setOpen(true);
setSearch("");
}
function select(id: string | null) {
onChange(id);
setOpen(false);
setSearch("");
inputRef.current?.blur();
}
return (
<div className={`relative ${className}`} ref={containerRef}>
<div className="relative">
<input
ref={inputRef}
type="text"
value={open ? search : selectedLabel}
onChange={(e) => setSearch(e.target.value)}
onFocus={handleFocus}
placeholder={placeholder}
disabled={disabled}
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed ${
open
? "border-brand-500 ring-2 ring-brand-500"
: "border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500"
} bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500`}
readOnly={!open}
/>
{value && !disabled && !open && (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); select(null); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none"
aria-label="Clear"
tabIndex={-1}
>
\u00d7
</button>
)}
</div>
{open && (
<div className="absolute left-0 right-0 top-full mt-1 z-[60] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden">
<ul className="max-h-52 overflow-y-auto py-1">
{resources.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-400 dark:text-gray-500">No results</li>
) : (
resources.map((r) => (
<li key={r.id}>
<button
type="button"
onMouseDown={() => select(r.id)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 dark:hover:bg-brand-950/40 ${
r.id === value
? "bg-brand-50 dark:bg-brand-950/40 text-brand-700 dark:text-brand-300 font-medium"
: "text-gray-700 dark:text-gray-200"
}`}
>
<span>{r.displayName}</span>
<span className="ml-1.5 text-xs text-gray-400 dark:text-gray-500">{r.eid}</span>
</button>
</li>
))
)}
</ul>
</div>
)}
</div>
<EntityCombobox<ResourceItem>
{...props}
placeholder={props.placeholder ?? "Search resource\u2026"}
useSearchQuery={useSearchQuery}
useSelectedQuery={useSelectedQuery}
getLabel={getLabel}
renderItem={renderItem}
/>
);
}
@@ -0,0 +1,59 @@
/**
* Shared hook for loading filter options used across dashboard widgets.
* Loads clients, countries, roles, and chapters once with long cache TTL.
*/
"use client";
import { useMemo } from "react";
import { trpc } from "~/lib/trpc/client.js";
export interface FilterOption {
value: string;
label: string;
}
export function useWidgetFilterOptions() {
const { data: clientsRaw } = trpc.clientEntity.list.useQuery(
{ isActive: true },
{ staleTime: 300_000 },
);
const { data: countriesRaw } = trpc.country.list.useQuery(
{ isActive: true },
{ staleTime: 300_000 },
);
const { data: rolesRaw } = trpc.role.list.useQuery(
{ isActive: true },
{ staleTime: 300_000 },
);
const clients = useMemo<FilterOption[]>(() => {
const list = (Array.isArray(clientsRaw) ? clientsRaw : (clientsRaw as any)?.clients ?? []) as Array<{ id: string; name: string }>;
return list.map((c) => ({ value: c.id, label: c.name }));
}, [clientsRaw]);
const countries = useMemo<FilterOption[]>(() => {
const list = (Array.isArray(countriesRaw) ? countriesRaw : []) as Array<{ id: string; name: string }>;
return list.map((c) => ({ value: c.id, label: c.name }));
}, [countriesRaw]);
const roles = useMemo<FilterOption[]>(() => {
const list = (Array.isArray(rolesRaw) ? rolesRaw : []) as Array<{ id: string; name: string }>;
return list.map((r) => ({ value: r.id, label: r.name }));
}, [rolesRaw]);
// Chapters are derived from roles or can be hardcoded common ones
const chapters = useMemo<FilterOption[]>(() => {
const common = [
"Digital Content Production",
"Project Management",
"Art Direction",
"CGI-Dev",
"Product Data Management",
];
return common.map((c) => ({ value: c, label: c }));
}, []);
return { clients, countries, roles, chapters };
}
+15 -8
View File
@@ -1,12 +1,19 @@
import * as Sentry from "@sentry/nextjs";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("../sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("../sentry.edge.config");
// Only load Sentry in production — the worker.js crash in dev mode
// (vendor-chunks/lib/worker.js MODULE_NOT_FOUND) makes the dev server unstable
if (process.env.NODE_ENV === "production") {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("../sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("../sentry.edge.config");
}
}
}
export const onRequestError = Sentry.captureRequestError;
export async function onRequestError(...args: unknown[]) {
if (process.env.NODE_ENV === "production") {
const Sentry = await import("@sentry/nextjs");
(Sentry.captureRequestError as Function)(...args);
}
}
+517
View File
@@ -0,0 +1,517 @@
import { FieldType, BlueprintTarget } from "@planarchy/shared";
import type { FieldOption } from "@planarchy/shared";
// ---------------------------------------------------------------------------
// Catalog types
// ---------------------------------------------------------------------------
export interface CatalogField {
key: string;
label: string;
type: FieldType;
category: string;
description: string;
options?: FieldOption[];
defaultValue?: unknown;
/** true = maps to a real model column; false = stored in dynamicFields JSONB */
builtIn: boolean;
}
export interface CatalogCategory {
name: string;
description: string;
}
// ---------------------------------------------------------------------------
// PROJECT catalog
// ---------------------------------------------------------------------------
export const PROJECT_CATEGORIES: CatalogCategory[] = [
{ name: "Client & Billing", description: "Client relationship and billing details" },
{ name: "Technical Specs", description: "Render pipeline and delivery format" },
{ name: "Scope & Delivery", description: "Approval rounds, revision budgets, complexity" },
];
export const PROJECT_FIELD_CATALOG: CatalogField[] = [
// -- Client & Billing --
{
key: "clientUnit",
label: "Client Unit",
type: FieldType.TEXT,
category: "Client & Billing",
description: "Business unit or division of the client",
builtIn: false,
},
{
key: "personHoursSold",
label: "Person-Hours Sold",
type: FieldType.NUMBER,
category: "Client & Billing",
description: "Total billable person-hours sold to the client",
builtIn: false,
},
{
key: "billingModel",
label: "Billing Model",
type: FieldType.SELECT,
category: "Client & Billing",
description: "How the project is billed",
options: [
{ value: "fixed", label: "Fixed Price" },
{ value: "tm", label: "Time & Material" },
{ value: "hybrid", label: "Hybrid" },
],
builtIn: false,
},
{
key: "poNumber",
label: "PO Number",
type: FieldType.TEXT,
category: "Client & Billing",
description: "Purchase order reference number",
builtIn: false,
},
{
key: "invoiceCycle",
label: "Invoice Cycle",
type: FieldType.SELECT,
category: "Client & Billing",
description: "How often invoices are sent",
options: [
{ value: "weekly", label: "Weekly" },
{ value: "biweekly", label: "Bi-weekly" },
{ value: "monthly", label: "Monthly" },
{ value: "milestone", label: "Per Milestone" },
{ value: "on_completion", label: "On Completion" },
],
builtIn: false,
},
// -- Technical Specs --
{
key: "renderEngine",
label: "Render Engine",
type: FieldType.SELECT,
category: "Technical Specs",
description: "Primary render engine used",
options: [
{ value: "vray", label: "V-Ray" },
{ value: "arnold", label: "Arnold" },
{ value: "redshift", label: "Redshift" },
{ value: "octane", label: "Octane" },
{ value: "cycles", label: "Cycles" },
{ value: "unreal", label: "Unreal Engine" },
{ value: "unity", label: "Unity" },
{ value: "other", label: "Other" },
],
builtIn: false,
},
{
key: "renderFarm",
label: "Render Farm",
type: FieldType.SELECT,
category: "Technical Specs",
description: "Render farm provider",
options: [
{ value: "internal", label: "Internal" },
{ value: "rebusfarm", label: "RebusFarm" },
{ value: "ranch", label: "Ranch Computing" },
{ value: "garagefarm", label: "GarageFarm" },
{ value: "aws", label: "AWS Deadline" },
{ value: "other", label: "Other" },
],
builtIn: false,
},
{
key: "deliveryFormat",
label: "Delivery Format",
type: FieldType.MULTI_SELECT,
category: "Technical Specs",
description: "Final deliverable formats",
options: [
{ value: "exr", label: "EXR" },
{ value: "png", label: "PNG" },
{ value: "mp4", label: "MP4 (H.264)" },
{ value: "mov_prores", label: "MOV (ProRes)" },
{ value: "tiff", label: "TIFF" },
{ value: "dpx", label: "DPX" },
{ value: "fbx", label: "FBX" },
{ value: "glb", label: "GLB/glTF" },
{ value: "usd", label: "USD" },
],
builtIn: false,
},
{
key: "frameRate",
label: "Frame Rate",
type: FieldType.SELECT,
category: "Technical Specs",
description: "Target frame rate for animation/video",
options: [
{ value: "24", label: "24 fps (Film)" },
{ value: "25", label: "25 fps (PAL)" },
{ value: "30", label: "30 fps (NTSC)" },
{ value: "48", label: "48 fps (HFR)" },
{ value: "60", label: "60 fps" },
],
builtIn: false,
},
{
key: "colorSpace",
label: "Color Space",
type: FieldType.SELECT,
category: "Technical Specs",
description: "Working color space",
options: [
{ value: "srgb", label: "sRGB" },
{ value: "aces", label: "ACES" },
{ value: "rec709", label: "Rec.709" },
{ value: "rec2020", label: "Rec.2020" },
{ value: "display_p3", label: "Display P3" },
{ value: "linear", label: "Linear" },
],
builtIn: false,
},
{
key: "resolution",
label: "Resolution",
type: FieldType.SELECT,
category: "Technical Specs",
description: "Output resolution",
options: [
{ value: "hd", label: "1920x1080 (Full HD)" },
{ value: "2k", label: "2048x1080 (2K)" },
{ value: "qhd", label: "2560x1440 (QHD)" },
{ value: "uhd", label: "3840x2160 (4K UHD)" },
{ value: "4k_dci", label: "4096x2160 (4K DCI)" },
{ value: "8k", label: "7680x4320 (8K)" },
{ value: "custom", label: "Custom" },
],
builtIn: false,
},
// -- Scope & Delivery --
{
key: "clientApprovalRounds",
label: "Client Approval Rounds",
type: FieldType.NUMBER,
category: "Scope & Delivery",
description: "Number of approval rounds included in the scope",
defaultValue: 2,
builtIn: false,
},
{
key: "revisionBudgetHours",
label: "Revision Budget (hours)",
type: FieldType.NUMBER,
category: "Scope & Delivery",
description: "Hours reserved for client-requested revisions",
builtIn: false,
},
{
key: "complexityLevel",
label: "Complexity Level",
type: FieldType.SELECT,
category: "Scope & Delivery",
description: "Overall project complexity assessment",
options: [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "very_high", label: "Very High" },
],
builtIn: false,
},
{
key: "shotCount",
label: "Shot Count",
type: FieldType.NUMBER,
category: "Scope & Delivery",
description: "Total number of shots or scenes",
builtIn: false,
},
{
key: "deliveryDate",
label: "Delivery Date",
type: FieldType.DATE,
category: "Scope & Delivery",
description: "Final delivery deadline to the client",
builtIn: false,
},
{
key: "nda",
label: "NDA Required",
type: FieldType.BOOLEAN,
category: "Scope & Delivery",
description: "Whether a non-disclosure agreement is in effect",
defaultValue: false,
builtIn: false,
},
{
key: "projectBrief",
label: "Project Brief URL",
type: FieldType.URL,
category: "Scope & Delivery",
description: "Link to the project brief or scope document",
builtIn: false,
},
];
// ---------------------------------------------------------------------------
// RESOURCE catalog
// ---------------------------------------------------------------------------
export const RESOURCE_CATEGORIES: CatalogCategory[] = [
{ name: "Person Info", description: "Basic employee information" },
{ name: "Organization", description: "Organizational placement and location" },
{ name: "Contract", description: "Contract terms and rates" },
{ name: "Skills & Work", description: "Technical skills and work preferences" },
];
export const RESOURCE_FIELD_CATALOG: CatalogField[] = [
// -- Person Info --
{
key: "nickname",
label: "Nickname",
type: FieldType.TEXT,
category: "Person Info",
description: "Preferred name or nickname",
builtIn: false,
},
{
key: "phone",
label: "Phone Number",
type: FieldType.TEXT,
category: "Person Info",
description: "Business phone number",
builtIn: false,
},
{
key: "personalEmail",
label: "Personal Email",
type: FieldType.EMAIL,
category: "Person Info",
description: "Personal/secondary email address",
builtIn: false,
},
{
key: "linkedInUrl",
label: "LinkedIn Profile",
type: FieldType.URL,
category: "Person Info",
description: "Link to LinkedIn profile",
builtIn: false,
},
{
key: "startDate",
label: "Start Date",
type: FieldType.DATE,
category: "Person Info",
description: "Employment start date",
builtIn: false,
},
// -- Organization --
{
key: "department",
label: "Department",
type: FieldType.TEXT,
category: "Organization",
description: "Department or team name",
builtIn: false,
},
{
key: "costCenter",
label: "Cost Center",
type: FieldType.TEXT,
category: "Organization",
description: "Accounting cost center code",
builtIn: false,
},
{
key: "officeLocation",
label: "Office Location",
type: FieldType.TEXT,
category: "Organization",
description: "Physical office location or site name",
builtIn: false,
},
{
key: "reportingTo",
label: "Reporting To",
type: FieldType.TEXT,
category: "Organization",
description: "Direct manager or supervisor name",
builtIn: false,
},
// -- Contract --
{
key: "contractType",
label: "Contract Type",
type: FieldType.SELECT,
category: "Contract",
description: "Type of employment contract",
options: [
{ value: "permanent", label: "Permanent" },
{ value: "fixed_term", label: "Fixed Term" },
{ value: "freelance", label: "Freelance" },
{ value: "internship", label: "Internship" },
{ value: "working_student", label: "Working Student" },
],
builtIn: false,
},
{
key: "contractEndDate",
label: "Contract End Date",
type: FieldType.DATE,
category: "Contract",
description: "End date for fixed-term contracts",
builtIn: false,
},
{
key: "probationEndDate",
label: "Probation End Date",
type: FieldType.DATE,
category: "Contract",
description: "End of probationary period",
builtIn: false,
},
{
key: "weeklyHours",
label: "Weekly Hours",
type: FieldType.NUMBER,
category: "Contract",
description: "Contracted weekly working hours",
defaultValue: 40,
builtIn: false,
},
// -- Skills & Work --
{
key: "primarySoftware",
label: "Primary Software",
type: FieldType.MULTI_SELECT,
category: "Skills & Work",
description: "Main software tools used",
options: [
{ value: "maya", label: "Maya" },
{ value: "3dsmax", label: "3ds Max" },
{ value: "blender", label: "Blender" },
{ value: "cinema4d", label: "Cinema 4D" },
{ value: "houdini", label: "Houdini" },
{ value: "zbrush", label: "ZBrush" },
{ value: "substance", label: "Substance 3D" },
{ value: "nuke", label: "Nuke" },
{ value: "aftereffects", label: "After Effects" },
{ value: "unreal", label: "Unreal Engine" },
{ value: "unity", label: "Unity" },
{ value: "photoshop", label: "Photoshop" },
],
builtIn: false,
},
{
key: "yearsOfExperience",
label: "Years of Experience",
type: FieldType.NUMBER,
category: "Skills & Work",
description: "Total years of professional experience",
builtIn: false,
},
{
key: "spokenLanguages",
label: "Spoken Languages",
type: FieldType.MULTI_SELECT,
category: "Skills & Work",
description: "Languages the person speaks",
options: [
{ value: "de", label: "German" },
{ value: "en", label: "English" },
{ value: "fr", label: "French" },
{ value: "es", label: "Spanish" },
{ value: "it", label: "Italian" },
{ value: "pt", label: "Portuguese" },
{ value: "zh", label: "Chinese" },
{ value: "ja", label: "Japanese" },
{ value: "ko", label: "Korean" },
{ value: "ru", label: "Russian" },
],
builtIn: false,
},
{
key: "timezone",
label: "Timezone",
type: FieldType.SELECT,
category: "Skills & Work",
description: "Primary working timezone",
options: [
{ value: "Europe/Berlin", label: "CET (Berlin)" },
{ value: "Europe/London", label: "GMT (London)" },
{ value: "America/New_York", label: "EST (New York)" },
{ value: "America/Los_Angeles", label: "PST (Los Angeles)" },
{ value: "Asia/Tokyo", label: "JST (Tokyo)" },
{ value: "Asia/Shanghai", label: "CST (Shanghai)" },
{ value: "Asia/Kolkata", label: "IST (Mumbai)" },
{ value: "Australia/Sydney", label: "AEDT (Sydney)" },
],
builtIn: false,
},
{
key: "remoteEligible",
label: "Remote Eligible",
type: FieldType.BOOLEAN,
category: "Skills & Work",
description: "Whether the person can work remotely",
defaultValue: false,
builtIn: false,
},
{
key: "specialization",
label: "Specialization",
type: FieldType.SELECT,
category: "Skills & Work",
description: "Primary area of specialization",
options: [
{ value: "modeling", label: "3D Modeling" },
{ value: "texturing", label: "Texturing" },
{ value: "rigging", label: "Rigging" },
{ value: "animation", label: "Animation" },
{ value: "lighting", label: "Lighting" },
{ value: "rendering", label: "Rendering" },
{ value: "compositing", label: "Compositing" },
{ value: "fx", label: "FX / Simulation" },
{ value: "concept", label: "Concept Art" },
{ value: "motion_design", label: "Motion Design" },
{ value: "td", label: "Technical Direction" },
{ value: "pipeline", label: "Pipeline / Tools" },
{ value: "generalist", label: "Generalist" },
],
builtIn: false,
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Return the catalog for a given blueprint target */
export function getCatalogForTarget(target: BlueprintTarget | string): CatalogField[] {
return target === BlueprintTarget.PROJECT
? PROJECT_FIELD_CATALOG
: RESOURCE_FIELD_CATALOG;
}
/** Return the categories for a given blueprint target */
export function getCategoriesForTarget(target: BlueprintTarget | string): CatalogCategory[] {
return target === BlueprintTarget.PROJECT
? PROJECT_CATEGORIES
: RESOURCE_CATEGORIES;
}
/** Look up a catalog field by key */
export function findCatalogField(
target: BlueprintTarget | string,
key: string,
): CatalogField | undefined {
return getCatalogForTarget(target).find((f) => f.key === key);
}
+1
View File
@@ -17,6 +17,7 @@ const config: Config = {
700: "rgb(var(--accent-700) / <alpha-value>)",
800: "rgb(var(--accent-800) / <alpha-value>)",
900: "rgb(var(--accent-900) / <alpha-value>)",
950: "rgb(var(--accent-900) / <alpha-value>)",
},
},
fontFamily: {
+94
View File
@@ -0,0 +1,94 @@
type GeminiSettings = {
geminiApiKey?: string | null;
geminiModel?: string | null;
};
/** Returns true if the settings have a Gemini API key configured. */
export function isGeminiConfigured(settings: GeminiSettings | null | undefined): boolean {
return !!settings?.geminiApiKey;
}
/**
* Generates an image using the Google Gemini API.
* @returns A base64 data URL of the generated image.
*/
export async function generateGeminiImage(
apiKey: string,
prompt: string,
model = "gemini-2.5-flash-image",
): Promise<string> {
const fullPrompt = `Generate a professional, cinematic cover image for a 3D production project. ${prompt}`;
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: fullPrompt }] }],
generationConfig: { responseModalities: ["TEXT", "IMAGE"] },
}),
},
);
if (!response.ok) {
const body = await response.text();
let msg = body;
try {
const parsed = JSON.parse(body) as { error?: { message?: string } };
if (parsed.error?.message) msg = parsed.error.message;
} catch {
/* keep raw */
}
throw new Error(`HTTP ${response.status}: ${msg}`);
}
const data = (await response.json()) as {
candidates?: Array<{
content?: {
parts?: Array<{
inlineData?: { data: string; mimeType: string };
text?: string;
}>;
};
}>;
};
const imagePart = data.candidates?.[0]?.content?.parts?.find(
(p) => p.inlineData,
);
if (!imagePart?.inlineData?.data) {
throw new Error("No image data returned from Gemini");
}
const base64 = imagePart.inlineData.data;
const mimeType = imagePart.inlineData.mimeType ?? "image/png";
return `data:${mimeType};base64,${base64}`;
}
/** Turns Gemini API errors into actionable human-readable messages. */
export function parseGeminiError(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err);
const lower = msg.toLowerCase();
if (lower.includes("400") || lower.includes("invalid")) {
return "Invalid request — check the Gemini model name and prompt.";
}
if (lower.includes("401") || lower.includes("unauthorized") || lower.includes("api_key_invalid") || lower.includes("api key not valid")) {
return "Invalid API key — make sure you copied it correctly from Google AI Studio.";
}
if (lower.includes("403") || lower.includes("forbidden") || lower.includes("permission")) {
return "Access denied — your API key may not have permission to use image generation.";
}
if (lower.includes("404") || lower.includes("not found")) {
return "Model not found — verify the Gemini model name is correct.";
}
if (lower.includes("429") || lower.includes("rate limit") || lower.includes("quota")) {
return "Rate limit or quota exceeded — wait a moment and try again.";
}
if (lower.includes("econnrefused") || lower.includes("enotfound") || lower.includes("fetch failed")) {
return "Cannot reach the Gemini API — check your network connection.";
}
return msg.replace(/^Error: /, "").slice(0, 300);
}
+2
View File
@@ -3,9 +3,11 @@ export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedu
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js";
export { logger } from "./lib/logger.js";
export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js";
export { createNotification, createNotificationsForUsers } from "./lib/create-notification.js";
export { checkBudgetThresholds } from "./lib/budget-alerts.js";
export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js";
export { checkChargeabilityAlerts } from "./lib/chargeability-alerts.js";
export { checkVacationConflicts, checkBatchVacationConflicts } from "./lib/vacation-conflicts.js";
export { lookupRate, type RateCardLookupParams, type RateCardLookupResult } from "./lib/rate-card-lookup.js";
export { autoImportPublicHolidays, type AutoImportResult } from "./lib/holiday-auto-import.js";
export { createAuditEntry, computeDiff, generateSummary } from "./lib/audit.js";
+130
View File
@@ -0,0 +1,130 @@
import type { PrismaClient, Prisma } from "@planarchy/db";
import { logger } from "./logger.js";
type AuditAction = "CREATE" | "UPDATE" | "DELETE" | "SHIFT" | "IMPORT";
type AuditSource = "ui" | "api" | "ai" | "import" | "cron";
interface CreateAuditEntryParams {
db: PrismaClient;
entityType: string;
entityId: string;
entityName?: string;
action: AuditAction;
userId?: string;
before?: Record<string, unknown>;
after?: Record<string, unknown>;
source?: AuditSource;
summary?: string;
metadata?: Record<string, unknown>;
}
const INTERNAL_FIELDS = new Set(["id", "createdAt", "updatedAt"]);
/**
* Compare two snapshots and return only the changed fields.
* Skips internal fields (id, createdAt, updatedAt).
* Uses JSON.stringify for nested object comparison.
*/
export function computeDiff(
before: Record<string, unknown>,
after: Record<string, unknown>,
): Record<string, { old: unknown; new: unknown }> {
const diff: Record<string, { old: unknown; new: unknown }> = {};
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
for (const key of allKeys) {
if (INTERNAL_FIELDS.has(key)) continue;
const oldVal = before[key];
const newVal = after[key];
// Compare by JSON serialization to handle nested objects/arrays
const oldStr = JSON.stringify(oldVal) ?? "undefined";
const newStr = JSON.stringify(newVal) ?? "undefined";
if (oldStr !== newStr) {
diff[key] = { old: oldVal, new: newVal };
}
}
return diff;
}
/**
* Auto-generate a human-readable summary from the action and diff.
*/
export function generateSummary(
action: string,
entityType: string,
diff?: Record<string, { old: unknown; new: unknown }>,
): string {
switch (action) {
case "CREATE":
return `Created ${entityType}`;
case "DELETE":
return `Deleted ${entityType}`;
case "SHIFT":
return `Shifted ${entityType}`;
case "IMPORT":
return `Imported ${entityType}`;
case "UPDATE": {
if (!diff || Object.keys(diff).length === 0) {
return `Updated ${entityType}`;
}
const fields = Object.keys(diff);
if (fields.length <= 3) {
return `Updated ${fields.join(", ")}`;
}
return `Updated ${fields.slice(0, 3).join(", ")} and ${fields.length - 3} more`;
}
default:
return `${action} ${entityType}`;
}
}
/**
* Create an audit log entry. Fire-and-forget — errors are logged but never thrown.
*
* If both `before` and `after` are provided, a diff is computed automatically.
* If no `summary` is given, one is generated from the action and diff.
*/
export async function createAuditEntry(params: CreateAuditEntryParams): Promise<void> {
try {
const { db, entityType, entityId, entityName, action, userId, before, after, source, metadata } = params;
// Compute diff if both snapshots are available
const diff = before && after ? computeDiff(before, after) : undefined;
// Skip UPDATE entries where nothing actually changed
if (action === "UPDATE" && diff && Object.keys(diff).length === 0) {
return;
}
// Auto-generate summary if not provided
const summary = params.summary ?? generateSummary(action, entityType, diff);
// Build the changes JSONB payload
const changes: Record<string, unknown> = {};
if (before) changes.before = before;
if (after) changes.after = after;
if (diff) changes.diff = diff;
if (metadata) changes.metadata = metadata;
await db.auditLog.create({
data: {
entityType,
entityId,
action,
userId: userId ?? null,
changes: changes as unknown as Prisma.InputJsonValue,
source: source ?? null,
entityName: entityName ?? null,
summary,
},
});
} catch (error) {
// Fire-and-forget: log but never propagate
logger.error({ err: error, entityType: params.entityType, entityId: params.entityId }, "Failed to create audit entry");
}
}
+14 -19
View File
@@ -1,7 +1,7 @@
import { listAssignmentBookings } from "@planarchy/application";
import { rankResources } from "@planarchy/staffing";
import type { SkillEntry } from "@planarchy/shared";
import { emitNotificationCreated } from "../sse/event-bus.js";
import { createNotificationsForUsers } from "./create-notification.js";
/**
* Minimal DB interface for auto-staffing — avoids importing the full PrismaClient.
@@ -227,24 +227,19 @@ export async function generateAutoSuggestions(
select: { id: true },
});
for (const manager of managers) {
const notification = await db.notification.create({
data: {
userId: manager.id,
type: "AUTO_STAFFING_SUGGESTION",
category: "NOTIFICATION",
priority: "NORMAL",
title,
body,
entityId: demandRequirementId,
entityType: "demand",
link: `/staffing?demandId=${demandRequirementId}`,
channel: "in_app",
},
});
emitNotificationCreated(manager.id, notification.id);
}
await createNotificationsForUsers({
db,
userIds: managers.map((m) => m.id),
type: "AUTO_STAFFING_SUGGESTION",
category: "NOTIFICATION",
priority: "NORMAL",
title,
body,
entityId: demandRequirementId,
entityType: "demand",
link: `/staffing?demandId=${demandRequirementId}`,
channel: "in_app",
});
} catch {
// Fire-and-forget: swallow all errors to avoid disrupting the caller.
}
+14 -19
View File
@@ -1,5 +1,5 @@
import { listAssignmentBookings } from "@planarchy/application";
import { emitNotificationCreated } from "../sse/event-bus.js";
import { createNotificationsForUsers } from "./create-notification.js";
type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
project: {
@@ -119,23 +119,18 @@ export async function checkBudgetThresholds(
{ minimumFractionDigits: 2, maximumFractionDigits: 2 },
);
for (const manager of managers) {
const notification = await db.notification.create({
data: {
userId: manager.id,
type: threshold.type,
category: "NOTIFICATION",
priority: threshold.priority,
title: `Budget alert: ${project.name} has reached ${threshold.label} of budget`,
body: `Project ${project.shortCode} "${project.name}" has spent ${formattedSpend} EUR of ${formattedBudget} EUR budget (${Math.round(spendPercent)}%).`,
entityId: projectId,
entityType: "project_budget",
link: `/projects/${projectId}`,
channel: "in_app",
},
});
emitNotificationCreated(manager.id, notification.id);
}
await createNotificationsForUsers({
db,
userIds: managers.map((m) => m.id),
type: threshold.type,
category: "NOTIFICATION",
priority: threshold.priority,
title: `Budget alert: ${project.name} has reached ${threshold.label} of budget`,
body: `Project ${project.shortCode} "${project.name}" has spent ${formattedSpend} EUR of ${formattedBudget} EUR budget (${Math.round(spendPercent)}%).`,
entityId: projectId,
entityType: "project_budget",
link: `/projects/${projectId}`,
channel: "in_app",
});
}
}
+14 -19
View File
@@ -8,7 +8,7 @@ import {
import type { SpainScheduleRule } from "@planarchy/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application";
import { VacationStatus } from "@planarchy/db";
import { emitNotificationCreated } from "../sse/event-bus.js";
import { createNotificationsForUsers } from "./create-notification.js";
/**
* Minimal DB client type for chargeability alerts.
@@ -237,24 +237,19 @@ export async function checkChargeabilityAlerts(
if (existing) continue;
for (const manager of managers) {
const notification = await (db as DbClient).notification.create({
data: {
userId: manager.id,
type: "CHARGEABILITY_ALERT",
category: "NOTIFICATION",
priority: "HIGH",
title: `Low chargeability: ${resource.displayName}`,
body: `${resource.displayName} is at ${chg}% chargeability this month (target: ${target}%, gap: ${gap}pp).`,
entityId,
entityType: "chargeability_alert",
link: "/chargeability",
channel: "in_app",
},
});
emitNotificationCreated(manager.id, notification.id);
}
await createNotificationsForUsers({
db: db as DbClient,
userIds: managers.map((m) => m.id),
type: "CHARGEABILITY_ALERT",
category: "NOTIFICATION",
priority: "HIGH",
title: `Low chargeability: ${resource.displayName}`,
body: `${resource.displayName} is at ${chg}% chargeability this month (target: ${target}%, gap: ${gap}pp).`,
entityId,
entityType: "chargeability_alert",
link: "/chargeability",
channel: "in_app",
});
alertCount++;
}
+102
View File
@@ -0,0 +1,102 @@
import { emitNotificationCreated } from "../sse/event-bus.js";
export interface CreateNotificationParams {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db: { notification: { create: (args: any) => Promise<{ id: string; userId: string }> } };
userId: string;
type: string;
title: string;
body?: string | undefined;
link?: string | undefined;
entityId?: string | undefined;
entityType?: string | undefined;
category?: string | undefined;
priority?: string | undefined;
senderId?: string | undefined;
channel?: string | undefined;
taskStatus?: string | undefined;
taskAction?: string | undefined;
assigneeId?: string | undefined;
dueDate?: Date | undefined;
sourceId?: string | undefined;
/** Set to false to suppress the SSE emitNotificationCreated call. Default: true. */
emit?: boolean | undefined;
}
/**
* Create a single in-app notification and optionally emit an SSE event.
*
* Handles the `exactOptionalPropertyTypes` spread pattern internally so
* callers do not need to repeat the `...(val !== undefined ? { key: val } : {})` boilerplate.
*
* Returns the created notification's ID.
*/
export async function createNotification(
params: CreateNotificationParams,
): Promise<string> {
const {
db,
userId,
type,
title,
body,
link,
entityId,
entityType,
category,
priority,
senderId,
channel,
taskStatus,
taskAction,
assigneeId,
dueDate,
sourceId,
emit = true,
} = params;
const notification = await db.notification.create({
data: {
userId,
type,
title,
...(body !== undefined ? { body } : {}),
...(link !== undefined ? { link } : {}),
...(entityId !== undefined ? { entityId } : {}),
...(entityType !== undefined ? { entityType } : {}),
...(category !== undefined ? { category } : {}),
...(priority !== undefined ? { priority } : {}),
...(senderId !== undefined ? { senderId } : {}),
...(channel !== undefined ? { channel } : {}),
...(taskStatus !== undefined ? { taskStatus } : {}),
...(taskAction !== undefined ? { taskAction } : {}),
...(assigneeId !== undefined ? { assigneeId } : {}),
...(dueDate !== undefined ? { dueDate } : {}),
...(sourceId !== undefined ? { sourceId } : {}),
},
});
if (emit) {
emitNotificationCreated(userId, notification.id);
}
return notification.id;
}
/**
* Create one notification per user ID.
*
* Useful for fan-out scenarios (e.g. notifying all managers).
* Returns the count of notifications created.
*/
export async function createNotificationsForUsers(
params: Omit<CreateNotificationParams, "userId"> & { userIds: string[] },
): Promise<number> {
const { userIds, ...rest } = params;
let count = 0;
for (const userId of userIds) {
await createNotification({ ...rest, userId });
count++;
}
return count;
}
+14 -19
View File
@@ -1,4 +1,4 @@
import { emitNotificationCreated } from "../sse/event-bus.js";
import { createNotificationsForUsers } from "./create-notification.js";
type DbClient = {
estimate: {
@@ -138,24 +138,19 @@ export async function checkPendingEstimateReminders(
)
: REMINDER_DAYS;
for (const manager of managers) {
const notification = await db.notification.create({
data: {
userId: manager.id,
type: "ESTIMATE_APPROVAL_REMINDER",
category: "REMINDER",
priority: "HIGH",
title: `Estimate awaiting approval: ${estimate.name} (v${version.versionNumber})`,
body: `Estimate "${estimate.name}" version ${version.versionNumber} has been pending approval for ${daysPending} days.`,
entityId: version.id,
entityType: "estimate_approval_reminder",
link: `/estimates/${estimate.id}`,
channel: "in_app",
},
});
emitNotificationCreated(manager.id, notification.id);
}
await createNotificationsForUsers({
db,
userIds: managers.map((m) => m.id),
type: "ESTIMATE_APPROVAL_REMINDER",
category: "REMINDER",
priority: "HIGH",
title: `Estimate awaiting approval: ${estimate.name} (v${version.versionNumber})`,
body: `Estimate "${estimate.name}" version ${version.versionNumber} has been pending approval for ${daysPending} days.`,
entityId: version.id,
entityType: "estimate_approval_reminder",
link: `/estimates/${estimate.id}`,
channel: "in_app",
});
reminderCount++;
}
+13 -15
View File
@@ -1,5 +1,5 @@
import { VacationStatus } from "@planarchy/db";
import { emitNotificationCreated } from "../sse/event-bus.js";
import { createNotification } from "./create-notification.js";
type DbClient = {
vacation: {
@@ -189,21 +189,19 @@ export async function checkVacationConflicts(
// Create a notification for the approver if provided
if (approverUserId) {
const notification = await db.notification.create({
data: {
userId: approverUserId,
type: "VACATION_CONFLICT_WARNING",
category: "NOTIFICATION",
priority: "HIGH",
title: `Vacation conflict warning: ${vacation.resource.displayName}`,
body: warning,
entityId: vacationId,
entityType: "vacation",
link: "/vacations",
channel: "in_app",
},
await createNotification({
db,
userId: approverUserId,
type: "VACATION_CONFLICT_WARNING",
category: "NOTIFICATION",
priority: "HIGH",
title: `Vacation conflict warning: ${vacation.resource.displayName}`,
body: warning,
entityId: vacationId,
entityType: "vacation",
link: "/vacations",
channel: "in_app",
});
emitNotificationCreated(approverUserId, notification.id);
}
}
+282 -34
View File
@@ -4,7 +4,7 @@
*/
import { prisma } from "@planarchy/db";
import { calculateAllocation, countWorkingDays } from "@planarchy/engine/allocation";
import { calculateAllocation, checkDuplicateAssignment, countWorkingDays } from "@planarchy/engine/allocation";
import { computeBudgetStatus } from "@planarchy/engine";
import type { PermissionKey } from "@planarchy/shared";
import { parseTaskAction } from "@planarchy/shared";
@@ -276,7 +276,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function",
function: {
name: "navigate_to_page",
description: "Navigate the user to a specific page in Planarchy, optionally with filters. Use this when the user wants to see data on a specific page (e.g. 'show me on the timeline', 'open the resources page').",
description: "Navigate the user to a specific page in CapaKraken, optionally with filters. Use this when the user wants to see data on a specific page (e.g. 'show me on the timeline', 'open the resources page').",
parameters: {
type: "object",
properties: {
@@ -1020,7 +1020,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function",
function: {
name: "generate_project_cover",
description: "Generate an AI cover art image for a project using DALL-E. The image will be stored as the project's cover. Requires manageProjects permission and DALL-E to be configured.",
description: "Generate an AI cover art image for a project. Uses the configured image provider (DALL-E or Google Gemini). The image will be stored as the project's cover. Requires manageProjects permission.",
parameters: {
type: "object",
properties: {
@@ -1351,6 +1351,54 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
},
},
},
{
type: "function",
function: {
name: "query_change_history",
description: "Search the activity history for changes to projects, resources, allocations, vacations, or any entity. Can filter by entity type, entity name, user, date range, or action type.",
parameters: {
type: "object",
properties: {
entityType: { type: "string", description: "Filter by entity type (e.g. 'Project', 'Resource', 'Allocation', 'Vacation', 'Role', 'Estimate')" },
search: { type: "string", description: "Search in entity name or summary text" },
userId: { type: "string", description: "Filter by user ID who made the change" },
daysBack: { type: "integer", description: "How many days back to search. Default: 7" },
action: { type: "string", description: "Filter by action type: CREATE, UPDATE, DELETE, SHIFT, IMPORT" },
limit: { type: "integer", description: "Max results. Default: 20" },
},
},
},
},
{
type: "function",
function: {
name: "get_entity_timeline",
description: "Get the complete change history for a specific entity (project, resource, etc). Shows who made what changes and when.",
parameters: {
type: "object",
properties: {
entityType: { type: "string", description: "Entity type (e.g. 'Project', 'Resource', 'Allocation')" },
entityId: { type: "string", description: "Entity ID" },
limit: { type: "integer", description: "Max results. Default: 50" },
},
required: ["entityType", "entityId"],
},
},
},
{
type: "function",
function: {
name: "get_shoring_ratio",
description: "Get the onshore/offshore staffing ratio for a project. Higher offshore is better (cost-efficient). The threshold is the MINIMUM offshore target. Shows country breakdown and whether the target is met.",
parameters: {
type: "object",
properties: {
projectId: { type: "string", description: "Project ID or short code" },
},
required: ["projectId"],
},
},
},
];
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -2161,6 +2209,16 @@ const executors = {
const startDate = new Date(params.startDate);
const endDate = new Date(params.endDate);
// Check for overlapping duplicate assignments (same resource + project + overlapping dates)
const existingAssignments = await ctx.db.assignment.findMany({
where: { resourceId: resource.id, status: { not: "CANCELLED" } },
select: { id: true, resourceId: true, projectId: true, startDate: true, endDate: true, status: true },
});
const dupCheck = checkDuplicateAssignment(resource.id, project.id, startDate, endDate, existingAssignments);
if (dupCheck.isDuplicate) {
return { error: dupCheck.message + " Use update_allocation_status to modify the existing assignment." };
}
// Check for existing CANCELLED allocation with same unique key — reactivate it
const existing = await ctx.db.assignment.findUnique({
where: {
@@ -2968,6 +3026,16 @@ const executors = {
}
if (!resource) return { error: `Resource not found: ${params.resourceId}` };
// Check for overlapping duplicate assignments (same resource + project + overlapping dates)
const existingAssignments = await ctx.db.assignment.findMany({
where: { resourceId: resource.id, status: { not: "CANCELLED" } },
select: { id: true, resourceId: true, projectId: true, startDate: true, endDate: true, status: true },
});
const dupCheck = checkDuplicateAssignment(resource.id, demand.project.id, demand.startDate, demand.endDate, existingAssignments);
if (dupCheck.isDuplicate) {
return { error: dupCheck.message + " Use update_allocation_status to modify the existing assignment." };
}
const roleName = demand.roleEntity?.name ?? demand.role ?? null;
const dailyCostCents = Math.round(resource.lcrCents * demand.hoursPerDay);
const assignment = await ctx.db.assignment.create({
@@ -3759,43 +3827,64 @@ const executors = {
if (!project) return { error: `Project not found: ${params.projectId}` };
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
if (!isDalleConfigured(settings)) {
return { error: "DALL-E is not configured. Set up the DALL-E deployment in Admin → Settings." };
const imageProvider = settings?.imageProvider ?? "dalle";
const { isGeminiConfigured: isGeminiOk } = await import("../gemini-client.js");
const useGemini = imageProvider === "gemini" && isGeminiOk(settings);
const useDalle = imageProvider === "dalle" && isDalleConfigured(settings);
if (!useGemini && !useDalle) {
return { error: "No image provider configured. Set up DALL-E or Gemini in Admin → Settings." };
}
const clientName = project.client?.name ? ` for ${project.client.name}` : "";
const basePrompt = `Professional cover art for a 3D automotive visualization project: "${project.name}"${clientName}. Style: cinematic, modern, photorealistic CGI rendering, dramatic lighting, studio environment. No text or typography in the image.`;
const finalPrompt = params.prompt ? `${basePrompt} Additional direction: ${params.prompt}` : basePrompt;
const dalleClient = createDalleClient(settings!);
const model = settings!.aiProvider === "azure" ? settings!.azureDalleDeployment! : "dall-e-3";
let coverImageUrl: string;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response: any = await dalleClient.images.generate({
model,
prompt: finalPrompt,
size: "1024x1024",
n: 1,
response_format: "b64_json",
});
if (useGemini) {
try {
const { generateGeminiImage, parseGeminiError } = await import("../gemini-client.js");
coverImageUrl = await generateGeminiImage(
settings!.geminiApiKey!,
finalPrompt,
settings!.geminiModel ?? undefined,
);
} catch (err) {
const { parseGeminiError: parseErr } = await import("../gemini-client.js");
return { error: `Gemini error: ${parseErr(err)}` };
}
} else {
const dalleClient = createDalleClient(settings!);
const model = settings!.aiProvider === "azure" ? settings!.azureDalleDeployment! : "dall-e-3";
const b64 = response.data?.[0]?.b64_json;
if (!b64) return { error: "No image data returned from DALL-E" };
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response: any = await dalleClient.images.generate({
model,
prompt: finalPrompt,
size: "1024x1024",
n: 1,
response_format: "b64_json",
});
const coverImageUrl = `data:image/png;base64,${b64}`;
await ctx.db.project.update({ where: { id: params.projectId }, data: { coverImageUrl } });
return {
__action: "invalidate",
scope: ["project"],
success: true,
message: `Generated cover art for project "${project.name}"`,
coverImageUrl: coverImageUrl.slice(0, 100) + "...[truncated]",
};
} catch (err) {
return { error: `DALL-E error: ${parseAiError(err)}` };
const b64 = response.data?.[0]?.b64_json;
if (!b64) return { error: "No image data returned from DALL-E" };
coverImageUrl = `data:image/png;base64,${b64}`;
} catch (err) {
return { error: `DALL-E error: ${parseAiError(err)}` };
}
}
await ctx.db.project.update({ where: { id: params.projectId }, data: { coverImageUrl } });
return {
__action: "invalidate",
scope: ["project"],
success: true,
message: `Generated cover art for project "${project.name}" using ${useGemini ? "Gemini" : "DALL-E"}`,
coverImageUrl: coverImageUrl.slice(0, 100) + "...[truncated]",
};
},
async remove_project_cover(params: { projectId: string }, ctx: ToolContext) {
@@ -5339,6 +5428,162 @@ const executors = {
body: updated.body.slice(0, 100),
};
},
async query_change_history(params: {
entityType?: string;
search?: string;
userId?: string;
daysBack?: number;
action?: string;
limit?: number;
}, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 20, 50);
const daysBack = params.daysBack ?? 7;
const startDate = new Date();
startDate.setDate(startDate.getDate() - daysBack);
const where: Record<string, unknown> = {
createdAt: { gte: startDate },
};
if (params.entityType) where.entityType = params.entityType;
if (params.action) where.action = params.action;
if (params.userId) where.userId = params.userId;
if (params.search) {
where.OR = [
{ entityName: { contains: params.search, mode: "insensitive" } },
{ summary: { contains: params.search, mode: "insensitive" } },
{ entityType: { contains: params.search, mode: "insensitive" } },
];
}
const entries = await ctx.db.auditLog.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: limit,
});
if (entries.length === 0) {
return `No changes found in the last ${daysBack} days matching your criteria.`;
}
const lines = entries.map((e) => {
const who = e.user?.name ?? e.user?.email ?? "System";
const when = e.createdAt.toISOString().slice(0, 16).replace("T", " ");
const name = e.entityName ? ` "${e.entityName}"` : "";
const summary = e.summary ? `${e.summary}` : "";
return `[${when}] ${who}: ${e.action} ${e.entityType}${name}${summary}`;
});
return `Found ${entries.length} changes (last ${daysBack} days):\n\n${lines.join("\n")}`;
},
async get_entity_timeline(params: {
entityType: string;
entityId: string;
limit?: number;
}, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 50, 200);
const entries = await ctx.db.auditLog.findMany({
where: {
entityType: params.entityType,
entityId: params.entityId,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: limit,
});
if (entries.length === 0) {
return `No change history found for ${params.entityType} ${params.entityId}.`;
}
const entityName = entries[0]?.entityName ?? params.entityId;
const lines = entries.map((e) => {
const who = e.user?.name ?? e.user?.email ?? "System";
const when = e.createdAt.toISOString().slice(0, 16).replace("T", " ");
const summary = e.summary ?? e.action;
const source = e.source ? ` (via ${e.source})` : "";
// Include changed fields summary for UPDATE actions
const changes = e.changes as Record<string, unknown> | null;
const diff = changes?.diff as Record<string, { old: unknown; new: unknown }> | undefined;
let diffSummary = "";
if (diff && Object.keys(diff).length > 0) {
const fields = Object.entries(diff)
.slice(0, 3)
.map(([k, v]) => `${k}: ${JSON.stringify(v.old)}${JSON.stringify(v.new)}`)
.join("; ");
diffSummary = `\n Changed: ${fields}`;
if (Object.keys(diff).length > 3) {
diffSummary += ` (+${Object.keys(diff).length - 3} more)`;
}
}
return `[${when}] ${who}${source}: ${summary}${diffSummary}`;
});
return `Change history for ${params.entityType} "${entityName}" (${entries.length} entries):\n\n${lines.join("\n")}`;
},
async get_shoring_ratio(params: { projectId: string }, ctx: ToolContext) {
const sel = { id: true, name: true, shortCode: true, shoringThreshold: true, onshoreCountryCode: true } as const;
let project = await ctx.db.project.findUnique({ where: { id: params.projectId }, select: sel });
if (!project) {
project = await ctx.db.project.findUnique({ where: { shortCode: params.projectId }, select: sel });
}
if (!project) return { error: `Project not found: ${params.projectId}` };
const assignments = await ctx.db.assignment.findMany({
where: { projectId: project.id, status: { not: "CANCELLED" } },
include: { resource: { include: { country: { select: { code: true } } } } },
});
if (assignments.length === 0) {
return `Project "${project.name}" (${project.shortCode}): No active assignments — shoring ratio not available.`;
}
const { calculateShoringRatio: calcShoring } = await import("@planarchy/engine/allocation");
const mapped = assignments.map((a) => {
const start = new Date(a.startDate);
const end = new Date(a.endDate);
const diffDays = Math.max(1, Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1);
const workingDays = Math.max(1, Math.round(diffDays / 7 * 5));
return {
resourceId: a.resourceId,
countryCode: a.resource.country?.code ?? null,
hoursPerDay: a.hoursPerDay,
workingDays,
};
});
const threshold = project.shoringThreshold ?? 55;
const onshoreCode = project.onshoreCountryCode ?? "DE";
const result = calcShoring(mapped, threshold, onshoreCode);
const countryParts = Object.entries(result.byCountry)
.sort((a, b) => b[1].pct - a[1].pct)
.map(([code, info]) => `${code} ${info.pct}% (${info.resourceCount} people)`)
.join(", ");
const status = result.offshoreRatio >= threshold
? `Target met (>=${threshold}% offshore)`
: result.offshoreRatio >= threshold - 10
? `Close to target (${threshold}% offshore needed)`
: `Below target — only ${result.offshoreRatio}% offshore, need ${threshold}%`;
return `Project "${project.name}" (${project.shortCode}): ${result.onshoreRatio}% onshore (${onshoreCode}), ${result.offshoreRatio}% offshore. ${status}. Breakdown: ${countryParts}.${result.unknownCount > 0 ? ` (${result.unknownCount} resource(s) without country)` : ""}`;
},
};
// ─── Executor ───────────────────────────────────────────────────────────────
@@ -5383,16 +5628,19 @@ export async function executeTool(
if (actionType === "invalidate") {
const scope = actionResult.scope as string[];
// Strip __action and scope from the result sent back to the AI
const { __action: _, scope: _s, ...rest } = actionResult;
// Strip __action, scope, and large data from the result sent back to the AI
const { __action: _, scope: _s, coverImageUrl: _img, ...rest } = actionResult;
const content = JSON.stringify(rest);
return {
content: JSON.stringify(rest),
content: content.length > 4000 ? content.slice(0, 4000) + '..."' : content,
action: { type: "invalidate", scope },
};
}
}
return { content: JSON.stringify(result) };
// Cap tool result size to prevent oversized OpenAI conversation payloads
const content = typeof result === "string" ? result : JSON.stringify(result);
return { content: content.length > 8000 ? content.slice(0, 8000) + '..."' : content };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { content: JSON.stringify({ error: msg }) };
+1 -1
View File
@@ -12,7 +12,7 @@ import { TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from
const MAX_TOOL_ITERATIONS = 8;
const SYSTEM_PROMPT = `Du bist der plANARCHY-Assistent — ein hilfreicher AI-Assistent für Ressourcenplanung und Projektmanagement in einer 3D-Produktionsumgebung.
const SYSTEM_PROMPT = `Du bist der CapaKraken-Assistent — ein hilfreicher AI-Assistent für Ressourcenplanung und Projektmanagement in einer 3D-Produktionsumgebung.
Deine Fähigkeiten:
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur, Rollen, Blueprints, Rate Cards beantworten
+242
View File
@@ -0,0 +1,242 @@
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
// ─── Router ───────────────────────────────────────────────────────────────────
export const auditLogRouter = createTRPCRouter({
/**
* Paginated, filterable list of audit log entries.
* Cursor-based pagination using createdAt + id.
*/
list: controllerProcedure
.input(
z.object({
entityType: z.string().optional(),
entityId: z.string().optional(),
userId: z.string().optional(),
action: z.string().optional(),
source: z.string().optional(),
startDate: z.date().optional(),
endDate: z.date().optional(),
search: z.string().optional(),
limit: z.number().min(1).max(100).default(50),
cursor: z.string().optional(), // id of the last item
}),
)
.query(async ({ ctx, input }) => {
const { entityType, entityId, userId, action, source, startDate, endDate, search, limit, cursor } = input;
const where: Record<string, unknown> = {};
if (entityType) where.entityType = entityType;
if (entityId) where.entityId = entityId;
if (userId) where.userId = userId;
if (action) where.action = action;
if (source) where.source = source;
if (startDate || endDate) {
const createdAt: Record<string, Date> = {};
if (startDate) createdAt.gte = startDate;
if (endDate) createdAt.lte = endDate;
where.createdAt = createdAt;
}
if (search) {
where.OR = [
{ entityName: { contains: search, mode: "insensitive" } },
{ summary: { contains: search, mode: "insensitive" } },
{ entityType: { contains: search, mode: "insensitive" } },
];
}
// Default to last 30 days if no date filter to avoid full table scan
if (!startDate && !endDate && !entityId) {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
where.createdAt = { ...(where.createdAt as Record<string, Date> ?? {}), gte: thirtyDaysAgo };
}
const items = await ctx.db.auditLog.findMany({
where,
select: {
id: true,
entityType: true,
entityId: true,
entityName: true,
action: true,
userId: true,
source: true,
summary: true,
createdAt: true,
user: { select: { id: true, name: true, email: true } },
// Exclude 'changes' from list query — fetch on demand when expanding
},
orderBy: { createdAt: "desc" },
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
let nextCursor: string | undefined;
if (items.length > limit) {
const next = items.pop();
nextCursor = next?.id;
}
return { items, nextCursor };
}),
/**
* Get a single audit entry with full changes JSONB (for expand/detail view).
*/
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.db.auditLog.findUniqueOrThrow({
where: { id: input.id },
include: { user: { select: { id: true, name: true, email: true } } },
});
}),
/**
* Get all audit entries for a specific entity (e.g. a project or resource).
*/
getByEntity: controllerProcedure
.input(
z.object({
entityType: z.string(),
entityId: z.string(),
limit: z.number().min(1).max(200).default(50),
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.auditLog.findMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit,
});
}),
/**
* Timeline view: entries grouped by date (YYYY-MM-DD).
*/
getTimeline: controllerProcedure
.input(
z.object({
startDate: z.date().optional(),
endDate: z.date().optional(),
limit: z.number().min(1).max(500).default(200),
}),
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {};
if (input.startDate || input.endDate) {
const createdAt: Record<string, Date> = {};
if (input.startDate) createdAt.gte = input.startDate;
if (input.endDate) createdAt.lte = input.endDate;
where.createdAt = createdAt;
}
const entries = await ctx.db.auditLog.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit,
});
// Group by date string (YYYY-MM-DD)
const grouped: Record<string, typeof entries> = {};
for (const entry of entries) {
const dateKey = entry.createdAt.toISOString().slice(0, 10);
if (!grouped[dateKey]) grouped[dateKey] = [];
grouped[dateKey].push(entry);
}
return grouped;
}),
/**
* Activity summary: counts by entity type, action, and user for a date range.
*/
getActivitySummary: controllerProcedure
.input(
z.object({
startDate: z.date().optional(),
endDate: z.date().optional(),
}),
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {};
if (input.startDate || input.endDate) {
const createdAt: Record<string, Date> = {};
if (input.startDate) createdAt.gte = input.startDate;
if (input.endDate) createdAt.lte = input.endDate;
where.createdAt = createdAt;
}
// Run aggregation queries in parallel
const [byEntityTypeRaw, byActionRaw, byUserRaw, total] = await Promise.all([
ctx.db.auditLog.groupBy({
by: ["entityType"],
where,
_count: { id: true },
}),
ctx.db.auditLog.groupBy({
by: ["action"],
where,
_count: { id: true },
}),
ctx.db.auditLog.groupBy({
by: ["userId"],
where,
_count: { id: true },
orderBy: { _count: { id: "desc" } },
take: 20,
}),
ctx.db.auditLog.count({ where }),
]);
// Convert to simple Record<string, number>
const byEntityType: Record<string, number> = {};
for (const row of byEntityTypeRaw) {
byEntityType[row.entityType] = row._count.id;
}
const byAction: Record<string, number> = {};
for (const row of byActionRaw) {
byAction[row.action] = row._count.id;
}
// Resolve user names for the top users
const userIds = byUserRaw
.map((row) => row.userId)
.filter((id): id is string => id !== null);
const users = userIds.length > 0
? await ctx.db.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true },
})
: [];
const userMap = new Map(users.map((u) => [u.id, u.name ?? u.email]));
const byUser = byUserRaw
.filter((row) => row.userId !== null)
.map((row) => ({
name: userMap.get(row.userId!) ?? "Unknown",
count: row._count.id,
}));
return { byEntityType, byAction, byUser, total };
}),
});
+89 -7
View File
@@ -3,6 +3,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAuditEntry } from "../lib/audit.js";
export const blueprintRouter = createTRPCRouter({
list: protectedProcedure
@@ -35,7 +36,7 @@ export const blueprintRouter = createTRPCRouter({
create: adminProcedure
.input(CreateBlueprintSchema)
.mutation(async ({ ctx, input }) => {
return ctx.db.blueprint.create({
const blueprint = await ctx.db.blueprint.create({
data: {
name: input.name,
target: input.target,
@@ -45,17 +46,30 @@ export const blueprintRouter = createTRPCRouter({
validationRules: input.validationRules as unknown as import("@planarchy/db").Prisma.InputJsonValue,
} as unknown as Parameters<typeof ctx.db.blueprint.create>[0]["data"],
});
void createAuditEntry({
db: ctx.db,
entityType: "Blueprint",
entityId: blueprint.id,
entityName: blueprint.name,
action: "CREATE",
userId: ctx.dbUser?.id,
after: { name: input.name, target: input.target, description: input.description },
source: "ui",
});
return blueprint;
}),
update: adminProcedure
.input(z.object({ id: z.string(), data: UpdateBlueprintSchema }))
.mutation(async ({ ctx, input }) => {
await findUniqueOrThrow(
const before = await findUniqueOrThrow(
ctx.db.blueprint.findUnique({ where: { id: input.id } }),
"Blueprint",
);
return ctx.db.blueprint.update({
const updated = await ctx.db.blueprint.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
@@ -65,30 +79,71 @@ export const blueprintRouter = createTRPCRouter({
...(input.data.validationRules !== undefined ? { validationRules: input.data.validationRules as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
} as unknown as Parameters<typeof ctx.db.blueprint.update>[0]["data"],
});
void createAuditEntry({
db: ctx.db,
entityType: "Blueprint",
entityId: input.id,
entityName: updated.name,
action: "UPDATE",
userId: ctx.dbUser?.id,
before: before as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
}),
/** Dedicated mutation for saving role presets — separate from field defs to avoid Zod depth issues */
updateRolePresets: adminProcedure
.input(z.object({ id: z.string(), rolePresets: z.array(z.unknown()) }))
.mutation(async ({ ctx, input }) => {
await findUniqueOrThrow(
const before = await findUniqueOrThrow(
ctx.db.blueprint.findUnique({ where: { id: input.id } }),
"Blueprint",
);
return ctx.db.blueprint.update({
const updated = await ctx.db.blueprint.update({
where: { id: input.id },
data: { rolePresets: input.rolePresets as unknown as import("@planarchy/db").Prisma.InputJsonValue },
});
void createAuditEntry({
db: ctx.db,
entityType: "Blueprint",
entityId: input.id,
entityName: updated.name,
action: "UPDATE",
userId: ctx.dbUser?.id,
before: { rolePresets: before.rolePresets },
after: { rolePresets: input.rolePresets },
source: "ui",
summary: "Updated role presets",
});
return updated;
}),
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
// Soft delete — mark as inactive
return ctx.db.blueprint.update({
const deleted = await ctx.db.blueprint.update({
where: { id: input.id },
data: { isActive: false },
});
void createAuditEntry({
db: ctx.db,
entityType: "Blueprint",
entityId: input.id,
entityName: deleted.name,
action: "DELETE",
userId: ctx.dbUser?.id,
source: "ui",
});
return deleted;
}),
batchDelete: adminProcedure
@@ -100,6 +155,19 @@ export const blueprintRouter = createTRPCRouter({
ctx.db.blueprint.update({ where: { id }, data: { isActive: false } }),
),
);
for (const bp of updated) {
void createAuditEntry({
db: ctx.db,
entityType: "Blueprint",
entityId: bp.id,
entityName: bp.name,
action: "DELETE",
userId: ctx.dbUser?.id,
source: "ui",
});
}
return { count: updated.length };
}),
@@ -122,9 +190,23 @@ export const blueprintRouter = createTRPCRouter({
setGlobal: adminProcedure
.input(z.object({ id: z.string(), isGlobal: z.boolean() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.blueprint.update({
const updated = await ctx.db.blueprint.update({
where: { id: input.id },
data: { isGlobal: input.isGlobal },
});
void createAuditEntry({
db: ctx.db,
entityType: "Blueprint",
entityId: input.id,
entityName: updated.name,
action: "UPDATE",
userId: ctx.dbUser?.id,
after: { isGlobal: input.isGlobal },
source: "ui",
summary: input.isGlobal ? "Set blueprint as global" : "Removed global flag from blueprint",
});
return updated;
}),
});

Some files were not shown because too many files have changed in this diff Show More