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:
+18
-8
@@ -1,9 +1,9 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import { withSentryConfig } from "@sentry/nextjs";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
devIndicators: false,
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["recharts", "date-fns"],
|
optimizePackageImports: ["recharts", "date-fns"],
|
||||||
},
|
},
|
||||||
@@ -45,10 +45,20 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withSentryConfig(nextConfig, {
|
// Only wrap with Sentry in production — the worker.js crash in dev mode
|
||||||
silent: true,
|
// (vendor-chunks/lib/worker.js MODULE_NOT_FOUND) makes the dev server unstable
|
||||||
sourcemaps: {
|
// Sentry only in production — dynamic import avoids side effects in dev
|
||||||
disable: true,
|
let exportedConfig: NextConfig = nextConfig;
|
||||||
},
|
if (process.env.NODE_ENV === "production") {
|
||||||
telemetry: false,
|
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;
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@planarchy/api": "workspace:*",
|
"@planarchy/api": "workspace:*",
|
||||||
"@planarchy/application": "workspace:*",
|
"@planarchy/application": "workspace:*",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Planarchy — Resource Planning",
|
"name": "CapaKraken — Resource & Capacity Planning",
|
||||||
"short_name": "Planarchy",
|
"short_name": "CapaKraken",
|
||||||
"description": "Resource planning and project staffing for 3D production",
|
"description": "Resource planning and project staffing for 3D production",
|
||||||
"start_url": "/dashboard",
|
"start_url": "/dashboard",
|
||||||
"display": "standalone",
|
"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() {
|
export default function SkillImportRedirect() {
|
||||||
return <BatchSkillImport />;
|
redirect("/admin/imports?tab=skills");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
|
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
|
||||||
import { EntitlementManager } from "~/components/vacations/EntitlementManager.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() {
|
export default function AdminVacationsPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SkillMarketplace } from "~/components/analytics/SkillMarketplace.js";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function SkillMarketplacePage() {
|
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() {
|
export default function SkillsHubPage() {
|
||||||
return <SkillsAnalytics />;
|
return <SkillsHub />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -466,7 +466,7 @@ export function EstimatesClient() {
|
|||||||
No estimates yet
|
No estimates yet
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
|||||||
import { useRowOrder } from "~/hooks/useRowOrder.js";
|
import { useRowOrder } from "~/hooks/useRowOrder.js";
|
||||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||||
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.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";
|
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>
|
</td>
|
||||||
);
|
);
|
||||||
|
case "shoring":
|
||||||
|
return (
|
||||||
|
<td key={col.key} className="px-4 py-3 text-center">
|
||||||
|
<ShoringBadge projectId={project.id} />
|
||||||
|
</td>
|
||||||
|
);
|
||||||
case "responsible":
|
case "responsible":
|
||||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">—</td>;
|
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">—</td>;
|
||||||
default:
|
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 { 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 { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { CoverArtSection } from "~/components/projects/CoverArtSection.js";
|
import { CoverArtSection } from "~/components/projects/CoverArtSection.js";
|
||||||
|
import { ShoringIndicator } from "~/components/projects/ShoringIndicator.js";
|
||||||
|
|
||||||
const EDIT_ROLES = new Set(["ADMIN", "MANAGER"]);
|
const EDIT_ROLES = new Set(["ADMIN", "MANAGER"]);
|
||||||
|
|
||||||
@@ -133,6 +134,9 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
|
|||||||
{/* Budget status card (client component) */}
|
{/* Budget status card (client component) */}
|
||||||
<BudgetStatusCard projectId={project.id} />
|
<BudgetStatusCard projectId={project.id} />
|
||||||
|
|
||||||
|
{/* Nearshore ratio indicator (client component) */}
|
||||||
|
<ShoringIndicator projectId={project.id} />
|
||||||
|
|
||||||
{/* Assignments table (client component with delete action) */}
|
{/* Assignments table (client component with delete action) */}
|
||||||
<ProjectAssignmentsTable assignments={project.assignments as never} />
|
<ProjectAssignmentsTable assignments={project.assignments as never} />
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export default async function ScenarioPage({ params }: ScenarioPageProps) {
|
|||||||
<ScenarioPlanner
|
<ScenarioPlanner
|
||||||
projectId={id}
|
projectId={id}
|
||||||
baseline={baseline}
|
baseline={baseline}
|
||||||
resources={resources as never}
|
resources={((resources as any)?.resources ?? resources) as never}
|
||||||
roles={roles as never}
|
roles={(Array.isArray(roles) ? roles : []) as never}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1001,7 +1001,7 @@ export function ResourcesClient() {
|
|||||||
sortField={sortField}
|
sortField={sortField}
|
||||||
sortDir={sortDir}
|
sortDir={sortDir}
|
||||||
onSort={toggle}
|
onSort={toggle}
|
||||||
tooltip="Unique employee identifier used across all plANARCHY records."
|
tooltip="Unique employee identifier used across all CapaKraken records."
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "displayName":
|
case "displayName":
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ export async function generateMetadata(
|
|||||||
try {
|
try {
|
||||||
const trpc = await createCaller();
|
const trpc = await createCaller();
|
||||||
const resource = await trpc.resource.getById({ id });
|
const resource = await trpc.resource.getById({ id });
|
||||||
return { title: `${resource.displayName} — Resources | plANARCHY` };
|
return { title: `${resource.displayName} — Resources | CapaKraken` };
|
||||||
} catch {
|
} catch {
|
||||||
return { title: "Resource — plANARCHY" };
|
return { title: "Resource — CapaKraken" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const TimelineView = dynamic(
|
|||||||
|
|
||||||
export default function TimelinePage() {
|
export default function TimelinePage() {
|
||||||
return (
|
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 className="app-page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="app-page-title">Timeline</h1>
|
<h1 className="app-page-title">Timeline</h1>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js";
|
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() {
|
export default function MyVacationsPage() {
|
||||||
return <MyVacationsClient />;
|
return <MyVacationsClient />;
|
||||||
|
|||||||
@@ -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 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>
|
<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">
|
<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>
|
</span>
|
||||||
<h1 className="mt-6 font-display text-5xl font-semibold leading-tight text-gray-900 dark:text-gray-50">
|
<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.
|
Resource planning that stays readable under pressure.
|
||||||
@@ -66,7 +66,7 @@ export default function SignInPage() {
|
|||||||
<div className="app-surface-strong p-8">
|
<div className="app-surface-strong p-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-600">Welcome Back</p>
|
<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>
|
<p className="mt-2 text-sm text-gray-500">Resource Planning, staffing, and forecasting.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,23 +19,23 @@ const displayFont = Manrope({
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL("https://planarchy.hartmut-noerenberg.com"),
|
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",
|
description: "Interactive resource planning and project staffing tool",
|
||||||
manifest: "/manifest.json",
|
manifest: "/manifest.json",
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: "default",
|
statusBarStyle: "default",
|
||||||
title: "Planarchy",
|
title: "CapaKraken",
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "plANARCHY — Resource Planning",
|
title: "CapaKraken — Resource & Capacity Planning",
|
||||||
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
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",
|
type: "website",
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "plANARCHY — Resource Planning",
|
title: "CapaKraken — Resource & Capacity Planning",
|
||||||
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
||||||
images: ["/og-image.png"],
|
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">→</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">·</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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ const emptyRule: EditingRule = {
|
|||||||
|
|
||||||
export function CalculationRulesClient() {
|
export function CalculationRulesClient() {
|
||||||
const [editing, setEditing] = useState<EditingRule | null>(null);
|
const [editing, setEditing] = useState<EditingRule | null>(null);
|
||||||
|
const [confirmDeleteRule, setConfirmDeleteRule] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
@@ -219,7 +222,7 @@ export function CalculationRulesClient() {
|
|||||||
<td className="px-4 py-3 text-right text-sm">
|
<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={() => openEdit(rule)} className="mr-2 text-blue-600 hover:underline dark:text-blue-400">Edit</button>
|
||||||
<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"
|
className="text-red-600 hover:underline dark:text-red-400"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -240,9 +243,9 @@ export function CalculationRulesClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Edit/Create Modal ── */}
|
{/* ── Edit/Create Modal ── */}
|
||||||
{editing && (
|
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-lg">
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
{editing && (<>
|
||||||
<div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
|
<div className="p-6">
|
||||||
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{editing.id ? "Edit Rule" : "New Rule"}
|
{editing.id ? "Edit Rule" : "New Rule"}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -363,8 +366,22 @@ export function CalculationRulesClient() {
|
|||||||
{createMut.isPending || updateMut.isPending ? "Saving..." : "Save"}
|
{createMut.isPending || updateMut.isPending ? "Saving..." : "Save"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ export function CountriesClient() {
|
|||||||
const [editing, setEditing] = useState<EditingCountry | null>(null);
|
const [editing, setEditing] = useState<EditingCountry | null>(null);
|
||||||
const [cityName, setCityName] = useState("");
|
const [cityName, setCityName] = useState("");
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [confirmDeleteCity, setConfirmDeleteCity] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
@@ -236,11 +239,7 @@ export function CountriesClient() {
|
|||||||
{city.name}
|
{city.name}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setConfirmDeleteCity(city.id)}
|
||||||
if (confirm(`Delete metro city "${city.name}"?`)) {
|
|
||||||
deleteCityMut.mutate({ id: city.id });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-gray-400 hover:text-red-500 text-xs leading-none ml-1"
|
className="text-gray-400 hover:text-red-500 text-xs leading-none ml-1"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -274,9 +273,8 @@ export function CountriesClient() {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
{editing && (
|
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-lg" className="flex flex-col max-h-[90vh]">
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
{editing && (<>
|
||||||
<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]">
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<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">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{editing.id ? "Edit Country" : "Add Country"}
|
{editing.id ? "Edit Country" : "Add Country"}
|
||||||
@@ -406,8 +404,21 @@ export function CountriesClient() {
|
|||||||
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
|
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ export function EffortRulesClient() {
|
|||||||
|
|
||||||
const [editing, setEditing] = useState<EditingRuleSet | null>(null);
|
const [editing, setEditing] = useState<EditingRuleSet | null>(null);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
@@ -375,11 +377,7 @@ export function EffortRulesClient() {
|
|||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => setConfirmDelete(rs.id)}
|
||||||
if (confirm(`Delete rule set "${rs.name}"?`)) {
|
|
||||||
deleteMutation.mutate({ id: rs.id });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
|
className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -416,6 +414,20 @@ export function EffortRulesClient() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ export function ExperienceMultipliersClient() {
|
|||||||
|
|
||||||
const [editing, setEditing] = useState<EditingSet | null>(null);
|
const [editing, setEditing] = useState<EditingSet | null>(null);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
@@ -422,11 +424,7 @@ export function ExperienceMultipliersClient() {
|
|||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => setConfirmDelete(s.id)}
|
||||||
if (confirm(`Delete multiplier set "${s.name}"?`)) {
|
|
||||||
deleteMutation.mutate({ id: s.id });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
|
className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -471,6 +469,20 @@ export function ExperienceMultipliersClient() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ type EditingLevel = {
|
|||||||
export function ManagementLevelsClient() {
|
export function ManagementLevelsClient() {
|
||||||
const [editingGroup, setEditingGroup] = useState<EditingGroup | null>(null);
|
const [editingGroup, setEditingGroup] = useState<EditingGroup | null>(null);
|
||||||
const [editingLevel, setEditingLevel] = useState<EditingLevel | null>(null);
|
const [editingLevel, setEditingLevel] = useState<EditingLevel | null>(null);
|
||||||
|
const [confirmDeleteLevel, setConfirmDeleteLevel] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
@@ -185,11 +188,7 @@ export function ManagementLevelsClient() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setConfirmDeleteLevel(level.id)}
|
||||||
if (confirm(`Delete level "${level.name}"?`)) {
|
|
||||||
deleteLevelMut.mutate({ id: level.id });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-xs text-red-500 hover:text-red-700 font-medium"
|
className="text-xs text-red-500 hover:text-red-700 font-medium"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -207,9 +206,8 @@ export function ManagementLevelsClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Group Modal */}
|
{/* Group Modal */}
|
||||||
{editingGroup && (
|
<AnimatedModal open={editingGroup !== null} onClose={() => setEditingGroup(null)} maxWidth="max-w-md">
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
{editingGroup && (<>
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<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">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{editingGroup.id ? "Edit Group" : "Add Group"}
|
{editingGroup.id ? "Edit Group" : "Add Group"}
|
||||||
@@ -264,14 +262,12 @@ export function ManagementLevelsClient() {
|
|||||||
{isGroupPending ? "Saving..." : editingGroup.id ? "Update" : "Create"}
|
{isGroupPending ? "Saving..." : editingGroup.id ? "Update" : "Create"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>)}
|
||||||
</div>
|
</AnimatedModal>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Level Modal */}
|
{/* Level Modal */}
|
||||||
{editingLevel && (
|
<AnimatedModal open={editingLevel !== null} onClose={() => setEditingLevel(null)} maxWidth="max-w-sm">
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
{editingLevel && (<>
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-sm mx-4">
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<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">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{editingLevel.id ? "Edit Level" : "Add Level"}
|
{editingLevel.id ? "Edit Level" : "Add Level"}
|
||||||
@@ -316,8 +312,21 @@ export function ManagementLevelsClient() {
|
|||||||
{isLevelPending ? "Saving..." : editingLevel.id ? "Update" : "Create"}
|
{isLevelPending ? "Saving..." : editingLevel.id ? "Update" : "Create"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
@@ -195,9 +196,8 @@ export function OrgUnitsClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
{editing && (
|
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-md">
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
{editing && (<>
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<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">
|
<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}`}`}
|
{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"}
|
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>)}
|
||||||
</div>
|
</AnimatedModal>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||||
import { formatCents } from "~/lib/format.js";
|
import { formatCents } from "~/lib/format.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
@@ -101,6 +102,8 @@ export function RateCardsClient() {
|
|||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [editingCard, setEditingCard] = useState<EditingCard | null>(null);
|
const [editingCard, setEditingCard] = useState<EditingCard | null>(null);
|
||||||
const [editingLine, setEditingLine] = useState<EditingLine | 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 [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
@@ -260,7 +263,6 @@ export function RateCardsClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteLine(lineId: string) {
|
async function handleDeleteLine(lineId: string) {
|
||||||
if (!confirm("Delete this rate line?")) return;
|
|
||||||
try {
|
try {
|
||||||
await deleteLineMut.mutateAsync({ lineId });
|
await deleteLineMut.mutateAsync({ lineId });
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
@@ -270,7 +272,6 @@ export function RateCardsClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeactivate(id: string) {
|
async function handleDeactivate(id: string) {
|
||||||
if (!confirm("Deactivate this rate card?")) return;
|
|
||||||
try {
|
try {
|
||||||
await deactivateMut.mutateAsync({ id });
|
await deactivateMut.mutateAsync({ id });
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
@@ -445,7 +446,7 @@ export function RateCardsClient() {
|
|||||||
{detail.isActive ? (
|
{detail.isActive ? (
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="px-3 py-1.5 text-sm text-gray-500 hover:text-red-600 font-medium"
|
||||||
>
|
>
|
||||||
Deactivate
|
Deactivate
|
||||||
@@ -528,7 +529,7 @@ export function RateCardsClient() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeleteLine(line.id)}
|
onClick={() => setConfirmDeleteLine(line.id)}
|
||||||
className="text-xs text-red-500 hover:text-red-700 font-medium"
|
className="text-xs text-red-500 hover:text-red-700 font-medium"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -780,6 +781,34 @@ export function RateCardsClient() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,13 @@ export function SystemSettingsClient() {
|
|||||||
const [dalleEndpoint, setDalleEndpoint] = useState("");
|
const [dalleEndpoint, setDalleEndpoint] = useState("");
|
||||||
const [dalleApiKey, setDalleApiKey] = 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
|
// SMTP settings
|
||||||
const [smtpHost, setSmtpHost] = useState("");
|
const [smtpHost, setSmtpHost] = useState("");
|
||||||
const [smtpPort, setSmtpPort] = useState(587);
|
const [smtpPort, setSmtpPort] = useState(587);
|
||||||
@@ -144,6 +151,9 @@ export function SystemSettingsClient() {
|
|||||||
// DALL-E
|
// DALL-E
|
||||||
setDalleDeployment(settings.azureDalleDeployment ?? "");
|
setDalleDeployment(settings.azureDalleDeployment ?? "");
|
||||||
setDalleEndpoint(settings.azureDalleEndpoint ?? "");
|
setDalleEndpoint(settings.azureDalleEndpoint ?? "");
|
||||||
|
// Image provider / Gemini
|
||||||
|
setImageProvider((settings.imageProvider ?? "dalle") as ImageProvider);
|
||||||
|
setGeminiModel(settings.geminiModel ?? "");
|
||||||
// SMTP
|
// SMTP
|
||||||
setSmtpHost(settings.smtpHost ?? "");
|
setSmtpHost(settings.smtpHost ?? "");
|
||||||
setSmtpPort(settings.smtpPort ?? 587);
|
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() {
|
function handleSaveSmtp() {
|
||||||
saveSmtpMutation.mutate({
|
saveSmtpMutation.mutate({
|
||||||
smtpHost: smtpHost || undefined,
|
smtpHost: smtpHost || undefined,
|
||||||
@@ -259,6 +282,19 @@ export function SystemSettingsClient() {
|
|||||||
saveTimelineMutation.mutate({ timelineUndoMaxSteps: undoMaxSteps });
|
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() {
|
function handleSaveAnonymization() {
|
||||||
saveAnonymizationMutation.mutate({
|
saveAnonymizationMutation.mutate({
|
||||||
anonymizationEnabled,
|
anonymizationEnabled,
|
||||||
@@ -295,9 +331,6 @@ export function SystemSettingsClient() {
|
|||||||
aiTemperature: temperature,
|
aiTemperature: temperature,
|
||||||
aiSummaryPrompt: summaryPrompt || undefined,
|
aiSummaryPrompt: summaryPrompt || undefined,
|
||||||
...(apiKey ? { azureOpenAiApiKey: apiKey } : {}),
|
...(apiKey ? { azureOpenAiApiKey: apiKey } : {}),
|
||||||
azureDalleDeployment: dalleDeployment,
|
|
||||||
azureDalleEndpoint: provider === "azure" && dalleEndpoint ? dalleEndpoint : undefined,
|
|
||||||
...(dalleApiKey ? { azureDalleApiKey: dalleApiKey } : {}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1018,69 +1051,184 @@ export function SystemSettingsClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── DALL-E Image Generation ────────────────────────────────── */}
|
{/* ── Image Generation ────────────────────────────────── */}
|
||||||
<div className={PANEL_CLASS}>
|
<div className={PANEL_CLASS}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
<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>
|
</h2>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
{/* Provider selector */}
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL_CLASS}>
|
<label className={LABEL_CLASS}>Provider</label>
|
||||||
<span className="flex items-center">
|
<div className="flex gap-4">
|
||||||
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." />
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
</span>
|
<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>
|
</label>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
value={dalleDeployment}
|
|
||||||
onChange={(e) => setDalleDeployment(e.target.value)}
|
|
||||||
placeholder="dall-e-3"
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className={LABEL_CLASS}>
|
<label className={LABEL_CLASS}>
|
||||||
<span className="flex items-center">
|
<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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={INPUT_CLASS}
|
className={INPUT_CLASS}
|
||||||
value={dalleEndpoint}
|
value={dalleDeployment}
|
||||||
onChange={(e) => setDalleEndpoint(e.target.value)}
|
onChange={(e) => setDalleDeployment(e.target.value)}
|
||||||
placeholder="Leave empty to use same endpoint as chat"
|
placeholder="dall-e-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className={LABEL_CLASS}>
|
<label className={LABEL_CLASS}>
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
API Key{" "}
|
API Key <InfoTooltip content="Google Gemini API key from Google AI Studio (aistudio.google.com)." />
|
||||||
<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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
className={INPUT_CLASS}
|
className={INPUT_CLASS}
|
||||||
value={dalleApiKey}
|
value={geminiApiKey}
|
||||||
onChange={(e) => setDalleApiKey(e.target.value)}
|
onChange={(e) => setGeminiApiKey(e.target.value)}
|
||||||
placeholder="Leave empty to use same API key as chat"
|
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>
|
||||||
</>
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* ── SMTP / Email ──────────────────────────────────────────── */}
|
{/* ── SMTP / Email ──────────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, type PermissionOverrides } from "@planarchy/shared";
|
import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, type PermissionOverrides } from "@planarchy/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
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 { FilterChips } from "~/components/ui/FilterChips.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||||
@@ -90,6 +92,12 @@ export function UsersClient() {
|
|||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
|
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();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
@@ -166,6 +174,61 @@ export function UsersClient() {
|
|||||||
onError: (err) => setActionError(err.message),
|
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) {
|
function openEdit(user: UserRow) {
|
||||||
const role = (user.systemRole as SystemRole) ?? SystemRole.USER;
|
const role = (user.systemRole as SystemRole) ?? SystemRole.USER;
|
||||||
const overrides = user.permissionOverrides as PermissionOverrides | null;
|
const overrides = user.permissionOverrides as PermissionOverrides | null;
|
||||||
@@ -291,7 +354,8 @@ export function UsersClient() {
|
|||||||
updateRoleMutation.isPending ||
|
updateRoleMutation.isPending ||
|
||||||
setPermissionsMutation.isPending ||
|
setPermissionsMutation.isPending ||
|
||||||
resetPermissionsMutation.isPending ||
|
resetPermissionsMutation.isPending ||
|
||||||
createUserMutation.isPending;
|
createUserMutation.isPending ||
|
||||||
|
setPasswordMutation.isPending;
|
||||||
|
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
setSearch("");
|
setSearch("");
|
||||||
@@ -474,13 +538,26 @@ export function UsersClient() {
|
|||||||
{new Date(user.createdAt).toLocaleDateString("en-GB")}
|
{new Date(user.createdAt).toLocaleDateString("en-GB")}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button
|
<div className="flex items-center justify-end gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => openEdit(user)}
|
type="button"
|
||||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
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"
|
||||||
Edit
|
title="Set password"
|
||||||
</button>
|
>
|
||||||
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -488,6 +565,81 @@ export function UsersClient() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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 */}
|
{/* Create User Modal */}
|
||||||
{createState && (
|
{createState && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<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 */}
|
{/* Modal Body */}
|
||||||
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-6">
|
<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 */}
|
{/* System Role */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
@@ -159,9 +160,8 @@ export function UtilizationCategoriesClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
{editing && (
|
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-md">
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
{editing && (<>
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<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">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{editing.id ? "Edit Category" : "Add Category"}
|
{editing.id ? "Edit Category" : "Add Category"}
|
||||||
@@ -236,9 +236,8 @@ export function UtilizationCategoriesClient() {
|
|||||||
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
|
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>)}
|
||||||
</div>
|
</AnimatedModal>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export function WebhooksClient() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
|
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||||
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
|
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
|
||||||
import { AllocationStatus } from "@planarchy/shared";
|
import { AllocationStatus } from "@planarchy/shared";
|
||||||
@@ -73,6 +73,36 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
{ staleTime: 60_000 },
|
{ 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();
|
const invalidatePlanningViews = useInvalidatePlanningViews();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -473,6 +503,13 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
</div>
|
</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 */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-end gap-3 pt-2">
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,67 +3,17 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { PROFICIENCY_LABELS, ProficiencyBadge, GapIndicator, formatDate } from "~/components/analytics/skills/shared.js";
|
||||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
|
||||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||||
|
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
const SkillDistributionChart = dynamic(
|
const SkillDistributionChart = dynamic(
|
||||||
() => import("~/components/analytics/SkillDistributionChart.js"),
|
() => import("~/components/analytics/SkillDistributionChart.js"),
|
||||||
{ ssr: false, loading: () => <div className="h-80 shimmer-skeleton rounded-xl" /> },
|
{ 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() {
|
export function SkillMarketplace() {
|
||||||
const [searchSkill, setSearchSkill] = useState("");
|
const [searchSkill, setSearchSkill] = useState("");
|
||||||
const [minProficiency, setMinProficiency] = useState(1);
|
const [minProficiency, setMinProficiency] = useState(1);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useId } from "react";
|
import { useState, useId } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { PROFICIENCY_LABELS, proficiencyClasses, ProficiencyBadge } from "~/components/analytics/skills/shared.js";
|
||||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||||
import { trpc } from "~/lib/trpc/client.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" /> },
|
{ 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 (1–5), 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 };
|
type SkillRule = { skill: string; minProficiency: number };
|
||||||
|
|
||||||
export function SkillsAnalytics() {
|
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 · {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+ · Demand = unfilled demand requirements · 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 "{debouncedSearch}" 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 { BlueprintTarget } from "@planarchy/shared";
|
||||||
import type { BlueprintFieldDefinition } from "@planarchy/shared";
|
import type { BlueprintFieldDefinition } from "@planarchy/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { BlueprintFieldEditor } from "./BlueprintFieldEditor.js";
|
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
|
||||||
import { useSelection } from "~/hooks/useSelection.js";
|
import { useSelection } from "~/hooks/useSelection.js";
|
||||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||||
@@ -369,7 +369,7 @@ export function BlueprintsClient() {
|
|||||||
const isProject = bp.target === "PROJECT";
|
const isProject = bp.target === "PROJECT";
|
||||||
|
|
||||||
return (
|
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">
|
<td className="px-3 py-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -380,20 +380,20 @@ export function BlueprintsClient() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3">
|
<td className="px-3 py-3">
|
||||||
<div className="min-w-0">
|
<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>}
|
{bp.description && <div className="text-xs text-gray-500 mt-0.5 truncate">{bp.description}</div>}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3">
|
<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}
|
{bp.target}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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 dark:text-gray-400">{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">{isProject ? presetCount : "—"}</td>
|
||||||
<td className="px-3 py-3 text-center">
|
<td className="px-3 py-3 text-center">
|
||||||
{bp.isGlobal ? (
|
{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
|
Global
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -496,9 +496,10 @@ export function BlueprintsClient() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{editingBlueprint && (
|
{editingBlueprint && (
|
||||||
<BlueprintFieldEditor
|
<BlueprintFieldCatalog
|
||||||
blueprintId={editingBlueprint.id}
|
blueprintId={editingBlueprint.id}
|
||||||
blueprintName={editingBlueprint.name}
|
blueprintName={editingBlueprint.name}
|
||||||
|
blueprintTarget={editingBlueprint.target}
|
||||||
initialFieldDefs={Array.isArray(editingBlueprint.fieldDefs) ? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[]) : []}
|
initialFieldDefs={Array.isArray(editingBlueprint.fieldDefs) ? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[]) : []}
|
||||||
initialRolePresets={Array.isArray(editingBlueprint.rolePresets) ? (editingBlueprint.rolePresets as import("@planarchy/shared").StaffingRequirement[]) : []}
|
initialRolePresets={Array.isArray(editingBlueprint.rolePresets) ? (editingBlueprint.rolePresets as import("@planarchy/shared").StaffingRequirement[]) : []}
|
||||||
initialTab={editingTab}
|
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 { useState } from "react";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||||
import { CommentInput } from "./CommentInput.js";
|
import { CommentInput } from "./CommentInput.js";
|
||||||
|
|
||||||
interface CommentAuthor {
|
interface CommentAuthor {
|
||||||
@@ -118,6 +119,7 @@ function SingleComment({
|
|||||||
isReply?: boolean;
|
isReply?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [showReplyInput, setShowReplyInput] = useState(false);
|
const [showReplyInput, setShowReplyInput] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const createMutation = trpc.comment.create.useMutation({
|
const createMutation = trpc.comment.create.useMutation({
|
||||||
@@ -199,11 +201,7 @@ function SingleComment({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setConfirmDelete(true)}
|
||||||
if (window.confirm("Delete this comment?")) {
|
|
||||||
deleteMutation.mutate({ id: comment.id });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
className="text-xs text-gray-400 hover:text-rose-600 dark:hover:text-rose-400"
|
className="text-xs text-gray-400 hover:text-rose-600 dark:hover:text-rose-400"
|
||||||
>
|
>
|
||||||
@@ -236,6 +234,20 @@ function SingleComment({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Render replies */}
|
||||||
{"replies" in comment && comment.replies.length > 0 && (
|
{"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">
|
<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}>
|
<div key={widget.id}>
|
||||||
<WidgetContainer
|
<WidgetContainer
|
||||||
title={widget.title ?? getWidget(widget.type).label}
|
title={widget.title ?? getWidget(widget.type).label}
|
||||||
|
description={getWidget(widget.type).description}
|
||||||
onRemove={() => removeWidget(widget.id)}
|
onRemove={() => removeWidget(widget.id)}
|
||||||
>
|
>
|
||||||
{renderWidget(widget.type, widget.config, (update) =>
|
{renderWidget(widget.type, widget.config, (update) =>
|
||||||
|
|||||||
@@ -4,31 +4,54 @@ import { motion } from "framer-motion";
|
|||||||
|
|
||||||
interface WidgetContainerProps {
|
interface WidgetContainerProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
description?: string;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WidgetContainer({ title, onRemove, children, isDragging }: WidgetContainerProps) {
|
export function WidgetContainer({ title, description, onRemove, children, isDragging }: WidgetContainerProps) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
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 ${
|
className={`flex flex-col h-full rounded-xl border overflow-hidden transition-all duration-200 ${
|
||||||
isDragging ? "shadow-lg border-brand-300" : "hover:border-brand-200 dark:hover:border-brand-800"
|
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 */}
|
{/* Header — clean, no background separation */}
|
||||||
<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">
|
<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">
|
||||||
<span className="text-sm font-semibold text-gray-700 truncate">{title}</span>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove();
|
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"
|
title="Remove widget"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Subtle separator */}
|
||||||
|
<div className="mx-4 border-t border-gray-100 dark:border-gray-800" />
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 overflow-auto p-4">{children}</div>
|
<div className="flex-1 overflow-auto p-4">{children}</div>
|
||||||
</motion.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";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.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 {
|
function colorClass(pct: number): string {
|
||||||
if (pct > 90) return "bg-red-500";
|
if (pct > 90) return "bg-red-500";
|
||||||
@@ -15,12 +19,34 @@ function textColorClass(pct: number): string {
|
|||||||
return "text-green-700";
|
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(
|
const { data, isLoading } = trpc.dashboard.getBudgetForecast.useQuery(
|
||||||
undefined,
|
undefined,
|
||||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
{ 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) {
|
if (isLoading && !data) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 pt-1">
|
<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) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
<div className="flex flex-col h-full">
|
||||||
No active projects with budgets.
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto h-full">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
<table className="w-full text-xs">
|
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
|
||||||
<thead className="bg-gray-50 sticky top-0">
|
<div className="overflow-auto flex-1">
|
||||||
<tr>
|
<table className="w-full text-xs">
|
||||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Project</th>
|
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
||||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Budget Usage</th>
|
<tr>
|
||||||
<th className="px-3 py-2 text-right font-medium text-gray-500">Burn/mo</th>
|
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||||
<th className="px-3 py-2 text-right font-medium text-gray-500">Exhaustion</th>
|
Project <InfoTooltip content="Active projects with a defined budget" />
|
||||||
</tr>
|
</th>
|
||||||
</thead>
|
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||||
<tbody className="divide-y divide-gray-100">
|
Budget Usage <InfoTooltip content="Percentage of total budget consumed by current allocations" />
|
||||||
{rows.map((row) => (
|
</th>
|
||||||
<tr key={row.shortCode} className="hover:bg-gray-50">
|
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||||
<td className="px-3 py-2 font-medium text-gray-900 max-w-[140px] truncate">
|
Burn/mo <InfoTooltip content="Monthly burn rate based on currently active allocations" />
|
||||||
<span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>
|
</th>
|
||||||
{row.projectName}
|
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||||
</td>
|
Exhaustion <InfoTooltip content="Projected date when budget will be fully consumed at the current burn rate" />
|
||||||
<td className="px-3 py-2">
|
</th>
|
||||||
<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>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
</table>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { trpc } from "~/lib/trpc/client.js";
|
|||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { AnimatedNumber } from "~/components/ui/AnimatedNumber.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 }) {
|
function UtilizationBar({ percent }: { percent: number }) {
|
||||||
const barColor =
|
const barColor =
|
||||||
@@ -71,9 +73,20 @@ function FilterDropdown({ label, children }: { label: string; children: ReactNod
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetProps) {
|
||||||
const config = _config as { topN?: number; watchlistThreshold?: number };
|
const config = _config as { topN?: number; watchlistThreshold?: number; chapter?: string; includeProposed?: boolean };
|
||||||
const [includeProposed, setIncludeProposed] = useState(false);
|
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 [showDeparted, setShowDeparted] = useState(false);
|
||||||
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
|
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
|
||||||
const [topSort, setTopSort] = useState<TopSortKey>("actual");
|
const [topSort, setTopSort] = useState<TopSortKey>("actual");
|
||||||
@@ -132,6 +145,23 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
|||||||
setWatchVisibleCount(batchSize);
|
setWatchVisibleCount(batchSize);
|
||||||
}, [batchSize, includeProposed, selectedCountryIds, showDeparted]);
|
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 pt-1">
|
<div className="flex flex-col gap-3 pt-1">
|
||||||
@@ -158,11 +188,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawTop = data?.top ?? [];
|
const top = ([...filteredTop]).sort((a, b) => {
|
||||||
const rawWatch = data?.watchlist ?? [];
|
|
||||||
const month = (data?.month as string) ?? "";
|
|
||||||
|
|
||||||
const top = ([...rawTop] as ChargeabilityRow[]).sort((a, b) => {
|
|
||||||
const mult = topDir === "asc" ? 1 : -1;
|
const mult = topDir === "asc" ? 1 : -1;
|
||||||
switch (topSort) {
|
switch (topSort) {
|
||||||
case "name":
|
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;
|
const mult = watchDir === "asc" ? 1 : -1;
|
||||||
switch (watchSort) {
|
switch (watchSort) {
|
||||||
case "name":
|
case "name":
|
||||||
@@ -233,9 +259,10 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col gap-2 overflow-hidden">
|
<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="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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
{month && (
|
||||||
<p className="text-xs text-gray-400 flex items-center gap-1">
|
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||||||
Period: {month}
|
Period: {month}
|
||||||
<InfoTooltip
|
<InfoTooltip
|
||||||
@@ -243,66 +270,56 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
|||||||
width="w-72"
|
width="w-72"
|
||||||
/>
|
/>
|
||||||
</p>
|
</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>
|
||||||
)}
|
<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 */}
|
{/* Top list */}
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.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 {
|
function healthDot(value: number): string {
|
||||||
if (value >= 70) return "bg-green-500";
|
if (value >= 70) return "bg-green-500";
|
||||||
@@ -10,17 +16,39 @@ function healthDot(value: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scoreBadge(score: number): string {
|
function scoreBadge(score: number): string {
|
||||||
if (score >= 70) return "bg-green-100 text-green-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";
|
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";
|
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(
|
const { data, isLoading } = trpc.dashboard.getProjectHealth.useQuery(
|
||||||
undefined,
|
undefined,
|
||||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
{ 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) {
|
if (isLoading && !data) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 pt-1">
|
<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) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
<div className="flex flex-col h-full">
|
||||||
No active projects found.
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto h-full">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
<table className="w-full text-xs">
|
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
|
||||||
<thead className="bg-gray-50 sticky top-0">
|
<div className="overflow-auto flex-1">
|
||||||
<tr>
|
<table className="w-full text-xs">
|
||||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Project</th>
|
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
||||||
<th className="px-3 py-2 text-center font-medium text-gray-500" title="Budget / Staffing / Timeline">
|
<tr>
|
||||||
B / S / T
|
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||||
</th>
|
Project <InfoTooltip content="Active projects scored across three health dimensions" />
|
||||||
<th className="px-3 py-2 text-right font-medium text-gray-500">Score</th>
|
</th>
|
||||||
</tr>
|
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
||||||
</thead>
|
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demands), Timeline health (within end date)" />
|
||||||
<tbody className="divide-y divide-gray-100">
|
</th>
|
||||||
{rows.map((row) => (
|
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
||||||
<tr key={row.shortCode} className="hover:bg-gray-50">
|
Shoring <InfoTooltip content="Offshore staffing ratio: percentage of hours from non-onshore resources. Color indicates threshold status." />
|
||||||
<td className="px-3 py-2 font-medium text-gray-900 max-w-[160px] truncate">
|
</th>
|
||||||
<span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>
|
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||||
{row.projectName}
|
Score <InfoTooltip content="Composite score: average of Budget, Staffing, and Timeline health (0-100)" />
|
||||||
</td>
|
</th>
|
||||||
<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>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
</table>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.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(
|
const { data, isLoading } = trpc.dashboard.getSkillGaps.useQuery(
|
||||||
undefined,
|
undefined,
|
||||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
{ 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) {
|
if (isLoading && !data) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 pt-1">
|
<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) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
<div className="flex flex-col h-full">
|
||||||
No skill gaps detected.
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto h-full">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
<table className="w-full text-xs">
|
<WidgetFilterBar filters={FILTERS} values={config} onChange={onConfigChange ?? (() => {})} />
|
||||||
<thead className="bg-gray-50 sticky top-0">
|
<div className="overflow-auto flex-1">
|
||||||
<tr>
|
<table className="w-full text-xs">
|
||||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Skill</th>
|
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
||||||
<th className="px-3 py-2 text-right font-medium text-gray-500">Demand</th>
|
<tr>
|
||||||
<th className="px-3 py-2 text-right font-medium text-gray-500">Supply</th>
|
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||||
<th className="px-3 py-2 text-center font-medium text-gray-500">Gap</th>
|
Skill <InfoTooltip content="Skills required by open demand positions" />
|
||||||
</tr>
|
</th>
|
||||||
</thead>
|
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||||
<tbody className="divide-y divide-gray-100">
|
Demand <InfoTooltip content="Number of unfilled demand requirements needing this skill" />
|
||||||
{rows.map((row) => {
|
</th>
|
||||||
const isShortage = row.gap < 0;
|
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||||
const isSurplus = row.gap > 0;
|
Supply <InfoTooltip content="Number of active resources with this skill at proficiency 3+" />
|
||||||
return (
|
</th>
|
||||||
<tr key={row.skill} className="hover:bg-gray-50">
|
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
||||||
<td className="px-3 py-2 font-medium text-gray-900 max-w-[180px] truncate">
|
Gap <InfoTooltip content="Supply minus Demand: negative (red) = shortage, positive (green) = surplus" />
|
||||||
{row.skill}
|
</th>
|
||||||
</td>
|
</tr>
|
||||||
<td className="px-3 py-2 text-right text-gray-700 tabular-nums">
|
</thead>
|
||||||
{row.demand}
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
</td>
|
{rows.map((row) => {
|
||||||
<td className="px-3 py-2 text-right text-gray-700 tabular-nums">
|
const isShortage = row.gap < 0;
|
||||||
{row.supply}
|
const isSurplus = row.gap > 0;
|
||||||
</td>
|
return (
|
||||||
<td className="px-3 py-2 text-center">
|
<tr key={row.skill} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[180px] truncate">
|
||||||
<span
|
{row.skill}
|
||||||
className={`inline-block w-2 h-2 rounded-full ${
|
</td>
|
||||||
isShortage
|
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
|
||||||
? "bg-red-500"
|
{row.demand}
|
||||||
: isSurplus
|
</td>
|
||||||
? "bg-green-500"
|
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
|
||||||
: "bg-gray-400"
|
{row.supply}
|
||||||
}`}
|
</td>
|
||||||
/>
|
<td className="px-3 py-2 text-center">
|
||||||
<span
|
<span className="inline-flex items-center gap-1.5">
|
||||||
className={`font-semibold tabular-nums ${
|
<span
|
||||||
isShortage
|
className={`inline-block w-2 h-2 rounded-full ${
|
||||||
? "text-red-700"
|
isShortage
|
||||||
: isSurplus
|
? "bg-red-500"
|
||||||
? "text-green-700"
|
: isSurplus
|
||||||
: "text-gray-500"
|
? "bg-green-500"
|
||||||
}`}
|
: "bg-gray-400"
|
||||||
>
|
}`}
|
||||||
{row.gap > 0 ? `+${row.gap}` : row.gap}
|
/>
|
||||||
|
<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>
|
||||||
</span>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,11 +59,13 @@ function StatCard({
|
|||||||
</ProgressRing>
|
</ProgressRing>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-50">
|
<div className="mt-2">
|
||||||
<AnimatedNumber value={value} suffix={suffix} />
|
<span className="text-2xl font-semibold text-gray-900 dark:text-gray-50">
|
||||||
</span>
|
<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>
|
</div>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.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";
|
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 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 [sortKey, setSortKey] = useState<SortKey>("score");
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||||
@@ -23,6 +33,28 @@ export function TopValueWidget({ config }: WidgetProps) {
|
|||||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
{ 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 pt-1">
|
<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 (sorted.length === 0) {
|
||||||
|
|
||||||
if (list.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center py-8 text-gray-400 text-sm">
|
<div className="flex flex-col h-full">
|
||||||
<p>No scores computed yet or you lack access.</p>
|
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
|
||||||
<p className="text-xs mt-1">Admins can recompute scores in Settings.</p>
|
<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>
|
</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 }) {
|
function Ind({ k }: { k: SortKey }) {
|
||||||
return sortKey === k
|
return sortKey === k
|
||||||
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "▲" : "▼"}</span>
|
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "\u25B2" : "\u25BC"}</span>
|
||||||
: <span className="text-[10px] ml-0.5 text-gray-300">⇅</span>;
|
: <span className="text-[10px] ml-0.5 text-gray-300">{"\u21C5"}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto h-full">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
<table className="w-full text-xs">
|
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
|
||||||
<thead className="bg-gray-50 sticky top-0">
|
<div className="overflow-auto flex-1">
|
||||||
<tr>
|
<table className="w-full text-xs">
|
||||||
<th className="px-3 py-2 text-left font-medium text-gray-500 w-8">
|
<thead className="bg-gray-50 sticky top-0">
|
||||||
<span className="inline-flex items-center">
|
<tr>
|
||||||
#
|
<th className="px-3 py-2 text-left font-medium text-gray-500 w-8">
|
||||||
<InfoTooltip content="Rank position based on the current sort order." />
|
<span className="inline-flex items-center">
|
||||||
</span>
|
#
|
||||||
</th>
|
<InfoTooltip content="Rank position based on the current sort order." />
|
||||||
<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 0–100.<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 ?? "—"}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</th>
|
||||||
<td className="px-3 py-2 text-right text-gray-700">{(r.lcrCents / 100).toFixed(0)}</td>
|
<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 0–100.<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>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-gray-100">
|
||||||
</table>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
interface ApplyEffortRulesProps {
|
interface ApplyEffortRulesProps {
|
||||||
@@ -17,6 +18,7 @@ export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffort
|
|||||||
const [selectedRuleSetId, setSelectedRuleSetId] = useState<string>("");
|
const [selectedRuleSetId, setSelectedRuleSetId] = useState<string>("");
|
||||||
const [mode, setMode] = useState<"replace" | "append">("replace");
|
const [mode, setMode] = useState<"replace" | "append">("replace");
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [confirmApply, setConfirmApply] = useState(false);
|
||||||
|
|
||||||
const previewQuery = trpc.effortRule.preview.useQuery(
|
const previewQuery = trpc.effortRule.preview.useQuery(
|
||||||
{ estimateId, ruleSetId: selectedRuleSetId },
|
{ estimateId, ruleSetId: selectedRuleSetId },
|
||||||
@@ -106,10 +108,7 @@ export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffort
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!selectedRuleSetId) return;
|
if (!selectedRuleSetId) return;
|
||||||
const action = mode === "replace" ? "replace all existing demand lines" : "append new demand lines";
|
setConfirmApply(true);
|
||||||
if (confirm(`This will ${action}. Continue?`)) {
|
|
||||||
applyMutation.mutate({ estimateId, ruleSetId: selectedRuleSetId, mode });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={!selectedRuleSetId || applyMutation.isPending}
|
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"
|
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 && (
|
{showPreview && previewQuery.isLoading && (
|
||||||
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { formatCents } from "~/lib/format.js";
|
import { formatCents } from "~/lib/format.js";
|
||||||
import { trpc } from "~/lib/trpc/client.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 [selectedSetId, setSelectedSetId] = useState<string>("");
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [confirmApply, setConfirmApply] = useState(false);
|
||||||
|
|
||||||
const previewQuery = trpc.experienceMultiplier.preview.useQuery(
|
const previewQuery = trpc.experienceMultiplier.preview.useQuery(
|
||||||
{ estimateId, multiplierSetId: selectedSetId },
|
{ estimateId, multiplierSetId: selectedSetId },
|
||||||
@@ -96,9 +98,7 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!selectedSetId) return;
|
if (!selectedSetId) return;
|
||||||
if (confirm("This will update cost/bill rates and hours on matching demand lines. Continue?")) {
|
setConfirmApply(true);
|
||||||
applyMutation.mutate({ estimateId, multiplierSetId: selectedSetId });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={!selectedSetId || applyMutation.isPending}
|
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"
|
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 && (
|
{showPreview && previewQuery.isLoading && (
|
||||||
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
|
<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>
|
</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>
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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" />
|
<input value={name} onChange={(event) => setName(event.target.value)} className={INPUT_CLS} placeholder="CGI Breakdown Q2 2026" />
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
<ProjectCombobox value={projectId} onChange={setProjectId} placeholder="Link to project" />
|
||||||
</div>
|
</div>
|
||||||
<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 key={line.id} className="rounded-3xl border border-gray-100 p-4">
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<div>
|
<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" />
|
<ResourceCombobox value={line.resourceId} onChange={(resourceId) => applyResource(resourceId, line.id)} placeholder="Search resource" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ export function DemandLineEditor({
|
|||||||
|
|
||||||
<div className="mb-4 grid gap-4 md:grid-cols-2">
|
<div className="mb-4 grid gap-4 md:grid-cols-2">
|
||||||
<label>
|
<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
|
<select
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
value={line.resourceId ?? ""}
|
value={line.resourceId ?? ""}
|
||||||
@@ -353,7 +353,7 @@ export function DemandLineEditor({
|
|||||||
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
<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="text-xs uppercase tracking-wide text-gray-400">Snapshot behavior</p>
|
||||||
<p className="mt-1 text-sm text-gray-700">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,9 +76,48 @@ function NotificationsIcon() {
|
|||||||
function BroadcastIcon() {
|
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>;
|
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() {
|
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>;
|
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 }) {
|
function CollapseIcon({ collapsed }: { collapsed: boolean }) {
|
||||||
return (
|
return (
|
||||||
@@ -144,8 +183,7 @@ const navSections: NavSection[] = [
|
|||||||
{
|
{
|
||||||
label: "Analytics",
|
label: "Analytics",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/analytics/skills", label: "Skills Analytics", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
|
{ href: "/analytics/skills", label: "Skills Hub", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
|
||||||
{ href: "/analytics/skill-marketplace", label: "Skill Marketplace", icon: <MarketplaceIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
|
||||||
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||||
{ href: "/reports/builder", label: "Report Builder", icon: <ReportBuilderIcon />, 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"] },
|
{ 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[] = [
|
const adminNavEntries: AdminEntry[] = [
|
||||||
{ href: "/admin/blueprints", label: "Blueprints", icon: <AdminIcon /> },
|
{ href: "/admin/blueprints", label: "Blueprints", icon: <BlueprintIcon /> },
|
||||||
{ href: "/admin/clients", label: "Clients", icon: <AdminIcon /> },
|
{ href: "/admin/clients", label: "Clients", icon: <ClientsIcon /> },
|
||||||
{
|
{
|
||||||
label: "ACN-Orga",
|
label: "ACN-Orga",
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
items: [
|
items: [
|
||||||
{ href: "/admin/countries", label: "Countries", icon: <AdminIcon /> },
|
{ href: "/admin/countries", label: "Countries", icon: <CountryIcon /> },
|
||||||
{ href: "/admin/org-units", label: "Org Units", icon: <AdminIcon /> },
|
{ href: "/admin/org-units", label: "Org Units", icon: <OrgUnitIcon /> },
|
||||||
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: <AdminIcon /> },
|
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: <CategoryIcon /> },
|
||||||
{ href: "/admin/management-levels", label: "Mgmt Levels", icon: <AdminIcon /> },
|
{ 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/calculation-rules", label: "Calc. Rules", icon: <CalcRulesIcon /> },
|
||||||
{ href: "/admin/users", label: "Users", icon: <AdminIcon /> },
|
{ href: "/admin/users", label: "Users", icon: <UsersIcon /> },
|
||||||
{ href: "/admin/system-roles", label: "System Roles", icon: <AdminIcon /> },
|
{ href: "/admin/system-roles", label: "System Roles", icon: <SystemRolesIcon /> },
|
||||||
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
|
{ href: "/admin/settings", label: "Settings", icon: <SettingsIcon /> },
|
||||||
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
|
|
||||||
{ href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> },
|
{ 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 && (
|
{!sidebarCollapsed && (
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<h1 className="font-display text-xl font-semibold text-gray-900 dark:text-gray-50">
|
<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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -849,7 +888,7 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
|
|||||||
<HamburgerIcon />
|
<HamburgerIcon />
|
||||||
</button>
|
</button>
|
||||||
<span className="ml-3 font-display text-sm font-semibold text-gray-900 dark:text-gray-50">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<PageTransition>{children}</PageTransition>
|
<PageTransition>{children}</PageTransition>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function InstallPrompt() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-50">
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-50">
|
||||||
Install Planarchy
|
Install CapaKraken
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Add to home screen for quick access
|
Add to home screen for quick access
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||||
import { TaskCard } from "./TaskCard.js";
|
import { TaskCard } from "./TaskCard.js";
|
||||||
@@ -31,6 +32,7 @@ export function NotificationCenterClient() {
|
|||||||
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
|
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
|
||||||
const { canEdit } = usePermissions();
|
const { canEdit } = usePermissions();
|
||||||
const [showTaskModal, setShowTaskModal] = useState(false);
|
const [showTaskModal, setShowTaskModal] = useState(false);
|
||||||
|
const [confirmDeleteReminder, setConfirmDeleteReminder] = useState<string | null>(null);
|
||||||
const [reminderModal, setReminderModal] = useState<{
|
const [reminderModal, setReminderModal] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
reminder: {
|
reminder: {
|
||||||
@@ -374,11 +376,7 @@ export function NotificationCenterClient() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setConfirmDeleteReminder(r.id)}
|
||||||
if (window.confirm("Delete this reminder?")) {
|
|
||||||
deleteReminder.mutate({ id: r.id });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={deleteReminder.isPending}
|
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"
|
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"
|
title="Delete"
|
||||||
@@ -413,6 +411,20 @@ export function NotificationCenterClient() {
|
|||||||
onSuccess={() => setShowTaskModal(false)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
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 { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { DateInput } from "~/components/ui/DateInput.js";
|
|
||||||
|
|
||||||
const RECURRENCE_OPTIONS = [
|
const RECURRENCE_OPTIONS = [
|
||||||
{ value: "", label: "None" },
|
{ value: "", label: "None" },
|
||||||
@@ -50,6 +51,7 @@ export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalPro
|
|||||||
const [recurrence, setRecurrence] = useState(reminder?.recurrence ?? "");
|
const [recurrence, setRecurrence] = useState(reminder?.recurrence ?? "");
|
||||||
const [link, setLink] = useState(reminder?.link ?? "");
|
const [link, setLink] = useState(reminder?.link ?? "");
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
useFocusTrap(panelRef, true);
|
useFocusTrap(panelRef, true);
|
||||||
@@ -128,8 +130,7 @@ export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalPro
|
|||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!reminder) return;
|
if (!reminder) return;
|
||||||
if (!window.confirm("Delete this reminder?")) return;
|
setConfirmDelete(true);
|
||||||
deleteMutation.mutate({ id: reminder.id });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
@@ -303,6 +304,20 @@ export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalPro
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function CoverArtSection({ projectId, coverImageUrl, coverFocusY = 50, pr
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const utils = trpc.useUtils();
|
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 generateMutation = trpc.project.generateCover.useMutation();
|
||||||
const uploadMutation = trpc.project.uploadCover.useMutation();
|
const uploadMutation = trpc.project.uploadCover.useMutation();
|
||||||
const removeMutation = trpc.project.removeCover.useMutation();
|
const removeMutation = trpc.project.removeCover.useMutation();
|
||||||
@@ -207,7 +207,7 @@ export function CoverArtSection({ projectId, coverImageUrl, coverFocusY = 50, pr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Generate with AI */}
|
{/* Generate with AI */}
|
||||||
{dalleStatus?.configured && (
|
{imageGenStatus?.configured && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ interface FormState {
|
|||||||
color: string;
|
color: string;
|
||||||
utilizationCategoryId: string;
|
utilizationCategoryId: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
shoringThreshold: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultForm(): FormState {
|
function getDefaultForm(): FormState {
|
||||||
@@ -68,6 +69,7 @@ function getDefaultForm(): FormState {
|
|||||||
color: "",
|
color: "",
|
||||||
utilizationCategoryId: "",
|
utilizationCategoryId: "",
|
||||||
clientId: "",
|
clientId: "",
|
||||||
|
shoringThreshold: "55",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +88,7 @@ function projectToForm(project: Project): FormState {
|
|||||||
color: (project as unknown as { color?: string | null }).color ?? "",
|
color: (project as unknown as { color?: string | null }).color ?? "",
|
||||||
utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
|
utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
|
||||||
clientId: (project as unknown as { clientId?: string | null }).clientId ?? "",
|
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),
|
startDate: new Date(form.startDate),
|
||||||
endDate: new Date(form.endDate),
|
endDate: new Date(form.endDate),
|
||||||
status: form.status as unknown as ProjectStatus,
|
status: form.status as unknown as ProjectStatus,
|
||||||
responsiblePerson: form.responsiblePerson.trim() || undefined,
|
responsiblePerson: form.responsiblePerson.trim(),
|
||||||
...(form.color ? { color: form.color } : {}),
|
...(form.color ? { color: form.color } : {}),
|
||||||
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
||||||
...(form.clientId ? { clientId: form.clientId } : {}),
|
...(form.clientId ? { clientId: form.clientId } : {}),
|
||||||
|
shoringThreshold: Number(form.shoringThreshold),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -223,10 +227,11 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
|
|||||||
status: form.status as unknown as ProjectStatus,
|
status: form.status as unknown as ProjectStatus,
|
||||||
staffingReqs: [],
|
staffingReqs: [],
|
||||||
dynamicFields: {},
|
dynamicFields: {},
|
||||||
responsiblePerson: form.responsiblePerson.trim() || undefined,
|
responsiblePerson: form.responsiblePerson.trim(),
|
||||||
...(form.color ? { color: form.color } : {}),
|
...(form.color ? { color: form.color } : {}),
|
||||||
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
||||||
...(form.clientId ? { clientId: form.clientId } : {}),
|
...(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">
|
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||||
Timeline & Budget
|
Timeline & Budget
|
||||||
</legend>
|
</legend>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass} htmlFor="startDate">
|
<label className={labelClass} htmlFor="startDate">
|
||||||
Start Date
|
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>
|
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.budgetEur}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@@ -521,8 +542,8 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass} htmlFor="responsiblePerson">
|
<label className={labelClass} htmlFor="responsiblePerson">
|
||||||
Responsible Person
|
Responsible Person <span className="text-red-500">*</span>
|
||||||
<InfoTooltip content="Project lead or account manager responsible for this project." />
|
<InfoTooltip content="Project lead or account manager responsible for this project. Required." />
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="responsiblePerson"
|
id="responsiblePerson"
|
||||||
@@ -530,6 +551,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
|
|||||||
value={form.responsiblePerson}
|
value={form.responsiblePerson}
|
||||||
onChange={(e) => setField("responsiblePerson", e.target.value)}
|
onChange={(e) => setField("responsiblePerson", e.target.value)}
|
||||||
placeholder="Name or EID"
|
placeholder="Name or EID"
|
||||||
|
required
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1089,7 +1089,7 @@ export function ProjectWizard({ open, onClose }: ProjectWizardProps) {
|
|||||||
endDate: new Date(state.endDate),
|
endDate: new Date(state.endDate),
|
||||||
staffingReqs: state.staffingReqs,
|
staffingReqs: state.staffingReqs,
|
||||||
status: state.saveAsDraft ? ProjectStatus.DRAFT : ProjectStatus.ACTIVE,
|
status: state.saveAsDraft ? ProjectStatus.DRAFT : ProjectStatus.ACTIVE,
|
||||||
responsiblePerson: state.responsiblePerson.trim() || undefined,
|
responsiblePerson: state.responsiblePerson.trim(),
|
||||||
blueprintId: state.blueprintId ?? undefined,
|
blueprintId: state.blueprintId ?? undefined,
|
||||||
dynamicFields: {},
|
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>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.footer}>plANARCHY · Confidential · {rows.length} allocations</Text>
|
<Text style={styles.footer}>CapaKraken · Confidential · {rows.length} allocations</Text>
|
||||||
</Page>
|
</Page>
|
||||||
</Document>
|
</Document>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
|
|||||||
|
|
||||||
{preview.matchedRoleName && (
|
{preview.matchedRoleName && (
|
||||||
<p className="text-xs text-gray-600">
|
<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>
|
<span className="font-semibold text-brand-700">{preview.matchedRoleName}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import type { RoleWithResourceCount } from "@planarchy/shared";
|
import type { RoleWithResourceCount } from "@planarchy/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
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";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = [
|
||||||
@@ -33,9 +33,6 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
|
|||||||
const [color, setColor] = useState(role?.color ?? PRESET_COLORS[0]!);
|
const [color, setColor] = useState(role?.color ?? PRESET_COLORS[0]!);
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
|
||||||
useFocusTrap(panelRef, true);
|
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const createMutation = trpc.role.create.useMutation({
|
const createMutation = trpc.role.create.useMutation({
|
||||||
@@ -82,19 +79,7 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
|
|||||||
const labelClass = "app-label";
|
const labelClass = "app-label";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<AnimatedModal open onClose={onClose} maxWidth="max-w-md">
|
||||||
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();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
|
<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">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{isEditing ? "Edit Role" : "New Role"}
|
{isEditing ? "Edit Role" : "New Role"}
|
||||||
@@ -199,7 +184,6 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</AnimatedModal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function StaffingPanel() {
|
|||||||
<div className="app-surface max-w-xl p-4">
|
<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="text-sm font-medium text-gray-900 dark:text-gray-100">How scoring works</p>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -137,47 +137,6 @@ type ProjectFlatRow =
|
|||||||
const EMPTY_DAY_METRICS: ProjectDayMetric[] = [];
|
const EMPTY_DAY_METRICS: ProjectDayMetric[] = [];
|
||||||
const SVG_XMLNS = "http://www.w3.org/2000/svg";
|
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 ──────────────────────────────────────────────────────────────
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function TimelineProjectPanelInner({
|
function TimelineProjectPanelInner({
|
||||||
@@ -432,11 +391,6 @@ function TimelineProjectPanelInner({
|
|||||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||||
const totalRowHeight = rowVirtualizer.getTotalSize();
|
const totalRowHeight = rowVirtualizer.getTotalSize();
|
||||||
|
|
||||||
const resourceRowGridStyle = useMemo(
|
|
||||||
() => buildProjectRowGridBackground(dates, CELL_WIDTH, today),
|
|
||||||
[CELL_WIDTH, dates, today],
|
|
||||||
);
|
|
||||||
|
|
||||||
const resourcesWithVacations = useMemo(() => {
|
const resourcesWithVacations = useMemo(() => {
|
||||||
const result = new Set<string>();
|
const result = new Set<string>();
|
||||||
for (const [resourceId, vacations] of vacationsByResource) {
|
for (const [resourceId, vacations] of vacationsByResource) {
|
||||||
@@ -713,7 +667,7 @@ function TimelineProjectPanelInner({
|
|||||||
totalCanvasWidth,
|
totalCanvasWidth,
|
||||||
toLeft,
|
toLeft,
|
||||||
toWidth,
|
toWidth,
|
||||||
resourceRowGridStyle,
|
gridLines,
|
||||||
onOpenDemandClick,
|
onOpenDemandClick,
|
||||||
onAllocMouseDown,
|
onAllocMouseDown,
|
||||||
onAllocTouchStart,
|
onAllocTouchStart,
|
||||||
@@ -750,7 +704,6 @@ function TimelineProjectPanelInner({
|
|||||||
width: totalCanvasWidth,
|
width: totalCanvasWidth,
|
||||||
height: ROW_HEIGHT,
|
height: ROW_HEIGHT,
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
...resourceRowGridStyle,
|
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
@@ -776,6 +729,7 @@ function TimelineProjectPanelInner({
|
|||||||
}}
|
}}
|
||||||
onMouseLeave={clearHoverTooltips}
|
onMouseLeave={clearHoverTooltips}
|
||||||
>
|
>
|
||||||
|
{gridLines}
|
||||||
{renderProjectUtilOverlay(
|
{renderProjectUtilOverlay(
|
||||||
projectRowMetrics.get(row.metricsKey) ?? EMPTY_DAY_METRICS,
|
projectRowMetrics.get(row.metricsKey) ?? EMPTY_DAY_METRICS,
|
||||||
CELL_WIDTH,
|
CELL_WIDTH,
|
||||||
@@ -889,7 +843,7 @@ function renderOpenDemandRow(
|
|||||||
totalCanvasWidth: number,
|
totalCanvasWidth: number,
|
||||||
toLeft: (d: Date) => number,
|
toLeft: (d: Date) => number,
|
||||||
toWidth: (s: Date, e: Date) => number,
|
toWidth: (s: Date, e: Date) => number,
|
||||||
rowGridStyle: CSSProperties,
|
rowGridLines: React.ReactNode,
|
||||||
_onOpenDemandClick: (demand: OpenDemandAssignment) => void,
|
_onOpenDemandClick: (demand: OpenDemandAssignment) => void,
|
||||||
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
||||||
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
||||||
@@ -934,8 +888,9 @@ function renderOpenDemandRow(
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
|
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-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" />
|
<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) => {
|
{openDemands.map((alloc) => {
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ function TimelineResourcePanelInner({
|
|||||||
orderType: string;
|
orderType: string;
|
||||||
hoursPerDay: number;
|
hoursPerDay: number;
|
||||||
responsiblePerson?: string | null;
|
responsiblePerson?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
status?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
}[];
|
}[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
@@ -295,6 +299,10 @@ function TimelineResourcePanelInner({
|
|||||||
orderType: string;
|
orderType: string;
|
||||||
hours: number;
|
hours: number;
|
||||||
responsiblePerson?: string | null;
|
responsiblePerson?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
status?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
for (const alloc of a) {
|
for (const alloc of a) {
|
||||||
@@ -314,6 +322,10 @@ function TimelineResourcePanelInner({
|
|||||||
hours: alloc.hoursPerDay,
|
hours: alloc.hoursPerDay,
|
||||||
responsiblePerson:
|
responsiblePerson:
|
||||||
(alloc.project as { responsiblePerson?: string | null }).responsiblePerson ?? null,
|
(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 (
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<ProjectCombobox
|
<ProjectCombobox
|
||||||
value={filters.projectIds[0] ?? null}
|
value={filters.projectIds[0] ?? null}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export type HeatmapHoverData = {
|
|||||||
orderType: string;
|
orderType: string;
|
||||||
hoursPerDay: number;
|
hoursPerDay: number;
|
||||||
responsiblePerson?: string | null;
|
responsiblePerson?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
status?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -81,10 +85,20 @@ export function TimelineTooltip({
|
|||||||
{entry.projectName}
|
{entry.projectName}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-[11px] text-gray-400">
|
<div className="truncate text-[11px] text-gray-400">
|
||||||
{entry.responsiblePerson
|
{[
|
||||||
? `Lead: ${entry.responsiblePerson}`
|
entry.role,
|
||||||
: entry.orderType}
|
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
|
||||||
|
entry.orderType,
|
||||||
|
].filter(Boolean).join(" · ")}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
||||||
{entry.hoursPerDay}h
|
{entry.hoursPerDay}h
|
||||||
@@ -146,10 +160,20 @@ export function TimelineTooltip({
|
|||||||
{entry.projectName}
|
{entry.projectName}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-[11px] text-gray-400">
|
<div className="truncate text-[11px] text-gray-400">
|
||||||
{entry.responsiblePerson
|
{[
|
||||||
? `Lead: ${entry.responsiblePerson}`
|
entry.role,
|
||||||
: entry.orderType}
|
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
|
||||||
|
entry.orderType,
|
||||||
|
].filter(Boolean).join(" · ")}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
||||||
{entry.hoursPerDay}h
|
{entry.hoursPerDay}h
|
||||||
|
|||||||
@@ -615,7 +615,7 @@ function TimelineViewContent({
|
|||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
onScroll={handleContainerScroll}
|
onScroll={handleContainerScroll}
|
||||||
className="app-surface relative flex-1 overflow-auto"
|
className="app-surface relative z-0 flex-1 overflow-auto"
|
||||||
>
|
>
|
||||||
{isInitialLoading ? (
|
{isInitialLoading ? (
|
||||||
<div className="flex items-center justify-center py-24 text-sm text-gray-500 dark:text-gray-400">
|
<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">→</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useCallback } from "react";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
|
||||||
import type { ProjectStatus } from "@planarchy/shared";
|
import type { ProjectStatus } from "@planarchy/shared";
|
||||||
|
import { EntityCombobox } from "./EntityCombobox.js";
|
||||||
|
|
||||||
|
type ProjectItem = { id: string; shortCode: string; name: string };
|
||||||
|
|
||||||
interface ProjectComboboxProps {
|
interface ProjectComboboxProps {
|
||||||
value: string | null;
|
value: string | null;
|
||||||
@@ -15,122 +17,48 @@ interface ProjectComboboxProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectCombobox({
|
export function ProjectCombobox({
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder = "Search project\u2026",
|
|
||||||
disabled = false,
|
|
||||||
status,
|
status,
|
||||||
className = "",
|
...props
|
||||||
}: ProjectComboboxProps) {
|
}: ProjectComboboxProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const useSearchQuery = (search: string, enabled: boolean) => {
|
||||||
const [search, setSearch] = useState("");
|
const { data } = trpc.project.list.useQuery(
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
{ search: search || undefined, limit: 15, ...(status ? { status } : {}) },
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
{ enabled, staleTime: 30_000 },
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
);
|
||||||
|
return { data: (data?.projects ?? []) as ProjectItem[] };
|
||||||
|
};
|
||||||
|
|
||||||
const { data } = trpc.project.list.useQuery(
|
const useSelectedQuery = (_id: string | null, enabled: boolean) => {
|
||||||
{ search: debouncedSearch || undefined, limit: 15, ...(status ? { status } : {}) },
|
const { data } = trpc.project.list.useQuery(
|
||||||
{ enabled: open, staleTime: 30_000 },
|
{ 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 renderItem = useCallback(
|
||||||
|
(p: ProjectItem) => (
|
||||||
const { data: allData } = trpc.project.list.useQuery(
|
<>
|
||||||
{ limit: 500 },
|
<span className="font-medium text-xs text-gray-400 dark:text-gray-500 mr-1.5">{p.shortCode}</span>
|
||||||
{ enabled: !!value && !open, staleTime: 60_000 },
|
<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 (
|
return (
|
||||||
<div className={`relative ${className}`} ref={containerRef}>
|
<EntityCombobox<ProjectItem>
|
||||||
<div className="relative">
|
{...props}
|
||||||
<input
|
placeholder={props.placeholder ?? "Search project\u2026"}
|
||||||
ref={inputRef}
|
useSearchQuery={useSearchQuery}
|
||||||
type="text"
|
useSelectedQuery={useSelectedQuery}
|
||||||
value={open ? search : selectedLabel}
|
getLabel={getLabel}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
renderItem={renderItem}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useCallback } from "react";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
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 {
|
interface ResourceComboboxProps {
|
||||||
value: string | null;
|
value: string | null;
|
||||||
@@ -14,123 +16,48 @@ interface ResourceComboboxProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ResourceCombobox({
|
export function ResourceCombobox({
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder = "Search resource\u2026",
|
|
||||||
disabled = false,
|
|
||||||
isActive = true,
|
isActive = true,
|
||||||
className = "",
|
...props
|
||||||
}: ResourceComboboxProps) {
|
}: ResourceComboboxProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const useSearchQuery = (search: string, enabled: boolean) => {
|
||||||
const [search, setSearch] = useState("");
|
const { data } = trpc.resource.list.useQuery(
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
{ search: search || undefined, limit: 15, isActive },
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
{ enabled, staleTime: 30_000 },
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
);
|
||||||
|
return { data: (data?.resources ?? []) as ResourceItem[] };
|
||||||
|
};
|
||||||
|
|
||||||
const { data } = trpc.resource.list.useQuery(
|
const useSelectedQuery = (_id: string | null, enabled: boolean) => {
|
||||||
{ search: debouncedSearch || undefined, limit: 15, isActive },
|
const { data } = trpc.resource.list.useQuery(
|
||||||
{ enabled: open, staleTime: 30_000 },
|
{ 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 renderItem = useCallback(
|
||||||
|
(r: ResourceItem) => (
|
||||||
const selectedQuery = trpc.resource.list.useQuery(
|
<>
|
||||||
{ limit: 500 },
|
<span>{r.displayName}</span>
|
||||||
{ enabled: !!value && !open, staleTime: 60_000 },
|
<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 (
|
return (
|
||||||
<div className={`relative ${className}`} ref={containerRef}>
|
<EntityCombobox<ResourceItem>
|
||||||
<div className="relative">
|
{...props}
|
||||||
<input
|
placeholder={props.placeholder ?? "Search resource\u2026"}
|
||||||
ref={inputRef}
|
useSearchQuery={useSearchQuery}
|
||||||
type="text"
|
useSelectedQuery={useSelectedQuery}
|
||||||
value={open ? search : selectedLabel}
|
getLabel={getLabel}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
renderItem={renderItem}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
|
|
||||||
export async function register() {
|
export async function register() {
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
// Only load Sentry in production — the worker.js crash in dev mode
|
||||||
await import("../sentry.server.config");
|
// (vendor-chunks/lib/worker.js MODULE_NOT_FOUND) makes the dev server unstable
|
||||||
}
|
if (process.env.NODE_ENV === "production") {
|
||||||
if (process.env.NEXT_RUNTIME === "edge") {
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
await import("../sentry.edge.config");
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ const config: Config = {
|
|||||||
700: "rgb(var(--accent-700) / <alpha-value>)",
|
700: "rgb(var(--accent-700) / <alpha-value>)",
|
||||||
800: "rgb(var(--accent-800) / <alpha-value>)",
|
800: "rgb(var(--accent-800) / <alpha-value>)",
|
||||||
900: "rgb(var(--accent-900) / <alpha-value>)",
|
900: "rgb(var(--accent-900) / <alpha-value>)",
|
||||||
|
950: "rgb(var(--accent-900) / <alpha-value>)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedu
|
|||||||
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js";
|
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js";
|
||||||
export { logger } from "./lib/logger.js";
|
export { logger } from "./lib/logger.js";
|
||||||
export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.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 { checkBudgetThresholds } from "./lib/budget-alerts.js";
|
||||||
export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js";
|
export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js";
|
||||||
export { checkChargeabilityAlerts } from "./lib/chargeability-alerts.js";
|
export { checkChargeabilityAlerts } from "./lib/chargeability-alerts.js";
|
||||||
export { checkVacationConflicts, checkBatchVacationConflicts } from "./lib/vacation-conflicts.js";
|
export { checkVacationConflicts, checkBatchVacationConflicts } from "./lib/vacation-conflicts.js";
|
||||||
export { lookupRate, type RateCardLookupParams, type RateCardLookupResult } from "./lib/rate-card-lookup.js";
|
export { lookupRate, type RateCardLookupParams, type RateCardLookupResult } from "./lib/rate-card-lookup.js";
|
||||||
export { autoImportPublicHolidays, type AutoImportResult } from "./lib/holiday-auto-import.js";
|
export { autoImportPublicHolidays, type AutoImportResult } from "./lib/holiday-auto-import.js";
|
||||||
|
export { createAuditEntry, computeDiff, generateSummary } from "./lib/audit.js";
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { listAssignmentBookings } from "@planarchy/application";
|
import { listAssignmentBookings } from "@planarchy/application";
|
||||||
import { rankResources } from "@planarchy/staffing";
|
import { rankResources } from "@planarchy/staffing";
|
||||||
import type { SkillEntry } from "@planarchy/shared";
|
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.
|
* Minimal DB interface for auto-staffing — avoids importing the full PrismaClient.
|
||||||
@@ -227,24 +227,19 @@ export async function generateAutoSuggestions(
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const manager of managers) {
|
await createNotificationsForUsers({
|
||||||
const notification = await db.notification.create({
|
db,
|
||||||
data: {
|
userIds: managers.map((m) => m.id),
|
||||||
userId: manager.id,
|
type: "AUTO_STAFFING_SUGGESTION",
|
||||||
type: "AUTO_STAFFING_SUGGESTION",
|
category: "NOTIFICATION",
|
||||||
category: "NOTIFICATION",
|
priority: "NORMAL",
|
||||||
priority: "NORMAL",
|
title,
|
||||||
title,
|
body,
|
||||||
body,
|
entityId: demandRequirementId,
|
||||||
entityId: demandRequirementId,
|
entityType: "demand",
|
||||||
entityType: "demand",
|
link: `/staffing?demandId=${demandRequirementId}`,
|
||||||
link: `/staffing?demandId=${demandRequirementId}`,
|
channel: "in_app",
|
||||||
channel: "in_app",
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
emitNotificationCreated(manager.id, notification.id);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Fire-and-forget: swallow all errors to avoid disrupting the caller.
|
// Fire-and-forget: swallow all errors to avoid disrupting the caller.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { listAssignmentBookings } from "@planarchy/application";
|
import { listAssignmentBookings } from "@planarchy/application";
|
||||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
import { createNotificationsForUsers } from "./create-notification.js";
|
||||||
|
|
||||||
type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
|
type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
|
||||||
project: {
|
project: {
|
||||||
@@ -119,23 +119,18 @@ export async function checkBudgetThresholds(
|
|||||||
{ minimumFractionDigits: 2, maximumFractionDigits: 2 },
|
{ minimumFractionDigits: 2, maximumFractionDigits: 2 },
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const manager of managers) {
|
await createNotificationsForUsers({
|
||||||
const notification = await db.notification.create({
|
db,
|
||||||
data: {
|
userIds: managers.map((m) => m.id),
|
||||||
userId: manager.id,
|
type: threshold.type,
|
||||||
type: threshold.type,
|
category: "NOTIFICATION",
|
||||||
category: "NOTIFICATION",
|
priority: threshold.priority,
|
||||||
priority: threshold.priority,
|
title: `Budget alert: ${project.name} has reached ${threshold.label} of budget`,
|
||||||
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)}%).`,
|
||||||
body: `Project ${project.shortCode} "${project.name}" has spent ${formattedSpend} EUR of ${formattedBudget} EUR budget (${Math.round(spendPercent)}%).`,
|
entityId: projectId,
|
||||||
entityId: projectId,
|
entityType: "project_budget",
|
||||||
entityType: "project_budget",
|
link: `/projects/${projectId}`,
|
||||||
link: `/projects/${projectId}`,
|
channel: "in_app",
|
||||||
channel: "in_app",
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
emitNotificationCreated(manager.id, notification.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import type { SpainScheduleRule } from "@planarchy/shared";
|
import type { SpainScheduleRule } from "@planarchy/shared";
|
||||||
import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application";
|
import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application";
|
||||||
import { VacationStatus } from "@planarchy/db";
|
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.
|
* Minimal DB client type for chargeability alerts.
|
||||||
@@ -237,24 +237,19 @@ export async function checkChargeabilityAlerts(
|
|||||||
|
|
||||||
if (existing) continue;
|
if (existing) continue;
|
||||||
|
|
||||||
for (const manager of managers) {
|
await createNotificationsForUsers({
|
||||||
const notification = await (db as DbClient).notification.create({
|
db: db as DbClient,
|
||||||
data: {
|
userIds: managers.map((m) => m.id),
|
||||||
userId: manager.id,
|
type: "CHARGEABILITY_ALERT",
|
||||||
type: "CHARGEABILITY_ALERT",
|
category: "NOTIFICATION",
|
||||||
category: "NOTIFICATION",
|
priority: "HIGH",
|
||||||
priority: "HIGH",
|
title: `Low chargeability: ${resource.displayName}`,
|
||||||
title: `Low chargeability: ${resource.displayName}`,
|
body: `${resource.displayName} is at ${chg}% chargeability this month (target: ${target}%, gap: ${gap}pp).`,
|
||||||
body: `${resource.displayName} is at ${chg}% chargeability this month (target: ${target}%, gap: ${gap}pp).`,
|
entityId,
|
||||||
entityId,
|
entityType: "chargeability_alert",
|
||||||
entityType: "chargeability_alert",
|
link: "/chargeability",
|
||||||
link: "/chargeability",
|
channel: "in_app",
|
||||||
channel: "in_app",
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
emitNotificationCreated(manager.id, notification.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
alertCount++;
|
alertCount++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
import { createNotificationsForUsers } from "./create-notification.js";
|
||||||
|
|
||||||
type DbClient = {
|
type DbClient = {
|
||||||
estimate: {
|
estimate: {
|
||||||
@@ -138,24 +138,19 @@ export async function checkPendingEstimateReminders(
|
|||||||
)
|
)
|
||||||
: REMINDER_DAYS;
|
: REMINDER_DAYS;
|
||||||
|
|
||||||
for (const manager of managers) {
|
await createNotificationsForUsers({
|
||||||
const notification = await db.notification.create({
|
db,
|
||||||
data: {
|
userIds: managers.map((m) => m.id),
|
||||||
userId: manager.id,
|
type: "ESTIMATE_APPROVAL_REMINDER",
|
||||||
type: "ESTIMATE_APPROVAL_REMINDER",
|
category: "REMINDER",
|
||||||
category: "REMINDER",
|
priority: "HIGH",
|
||||||
priority: "HIGH",
|
title: `Estimate awaiting approval: ${estimate.name} (v${version.versionNumber})`,
|
||||||
title: `Estimate awaiting approval: ${estimate.name} (v${version.versionNumber})`,
|
body: `Estimate "${estimate.name}" version ${version.versionNumber} has been pending approval for ${daysPending} days.`,
|
||||||
body: `Estimate "${estimate.name}" version ${version.versionNumber} has been pending approval for ${daysPending} days.`,
|
entityId: version.id,
|
||||||
entityId: version.id,
|
entityType: "estimate_approval_reminder",
|
||||||
entityType: "estimate_approval_reminder",
|
link: `/estimates/${estimate.id}`,
|
||||||
link: `/estimates/${estimate.id}`,
|
channel: "in_app",
|
||||||
channel: "in_app",
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
emitNotificationCreated(manager.id, notification.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
reminderCount++;
|
reminderCount++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { VacationStatus } from "@planarchy/db";
|
import { VacationStatus } from "@planarchy/db";
|
||||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
import { createNotification } from "./create-notification.js";
|
||||||
|
|
||||||
type DbClient = {
|
type DbClient = {
|
||||||
vacation: {
|
vacation: {
|
||||||
@@ -189,21 +189,19 @@ export async function checkVacationConflicts(
|
|||||||
|
|
||||||
// Create a notification for the approver if provided
|
// Create a notification for the approver if provided
|
||||||
if (approverUserId) {
|
if (approverUserId) {
|
||||||
const notification = await db.notification.create({
|
await createNotification({
|
||||||
data: {
|
db,
|
||||||
userId: approverUserId,
|
userId: approverUserId,
|
||||||
type: "VACATION_CONFLICT_WARNING",
|
type: "VACATION_CONFLICT_WARNING",
|
||||||
category: "NOTIFICATION",
|
category: "NOTIFICATION",
|
||||||
priority: "HIGH",
|
priority: "HIGH",
|
||||||
title: `Vacation conflict warning: ${vacation.resource.displayName}`,
|
title: `Vacation conflict warning: ${vacation.resource.displayName}`,
|
||||||
body: warning,
|
body: warning,
|
||||||
entityId: vacationId,
|
entityId: vacationId,
|
||||||
entityType: "vacation",
|
entityType: "vacation",
|
||||||
link: "/vacations",
|
link: "/vacations",
|
||||||
channel: "in_app",
|
channel: "in_app",
|
||||||
},
|
|
||||||
});
|
});
|
||||||
emitNotificationCreated(approverUserId, notification.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@planarchy/db";
|
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 { computeBudgetStatus } from "@planarchy/engine";
|
||||||
import type { PermissionKey } from "@planarchy/shared";
|
import type { PermissionKey } from "@planarchy/shared";
|
||||||
import { parseTaskAction } from "@planarchy/shared";
|
import { parseTaskAction } from "@planarchy/shared";
|
||||||
@@ -276,7 +276,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
|
|||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: "navigate_to_page",
|
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: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -1020,7 +1020,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
|
|||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: "generate_project_cover",
|
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: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -2161,6 +2209,16 @@ const executors = {
|
|||||||
const startDate = new Date(params.startDate);
|
const startDate = new Date(params.startDate);
|
||||||
const endDate = new Date(params.endDate);
|
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
|
// Check for existing CANCELLED allocation with same unique key — reactivate it
|
||||||
const existing = await ctx.db.assignment.findUnique({
|
const existing = await ctx.db.assignment.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -2968,6 +3026,16 @@ const executors = {
|
|||||||
}
|
}
|
||||||
if (!resource) return { error: `Resource not found: ${params.resourceId}` };
|
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 roleName = demand.roleEntity?.name ?? demand.role ?? null;
|
||||||
const dailyCostCents = Math.round(resource.lcrCents * demand.hoursPerDay);
|
const dailyCostCents = Math.round(resource.lcrCents * demand.hoursPerDay);
|
||||||
const assignment = await ctx.db.assignment.create({
|
const assignment = await ctx.db.assignment.create({
|
||||||
@@ -3759,43 +3827,64 @@ const executors = {
|
|||||||
if (!project) return { error: `Project not found: ${params.projectId}` };
|
if (!project) return { error: `Project not found: ${params.projectId}` };
|
||||||
|
|
||||||
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||||
if (!isDalleConfigured(settings)) {
|
const imageProvider = settings?.imageProvider ?? "dalle";
|
||||||
return { error: "DALL-E is not configured. Set up the DALL-E deployment in Admin → Settings." };
|
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 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 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 finalPrompt = params.prompt ? `${basePrompt} Additional direction: ${params.prompt}` : basePrompt;
|
||||||
|
|
||||||
const dalleClient = createDalleClient(settings!);
|
let coverImageUrl: string;
|
||||||
const model = settings!.aiProvider === "azure" ? settings!.azureDalleDeployment! : "dall-e-3";
|
|
||||||
|
|
||||||
try {
|
if (useGemini) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
try {
|
||||||
const response: any = await dalleClient.images.generate({
|
const { generateGeminiImage, parseGeminiError } = await import("../gemini-client.js");
|
||||||
model,
|
coverImageUrl = await generateGeminiImage(
|
||||||
prompt: finalPrompt,
|
settings!.geminiApiKey!,
|
||||||
size: "1024x1024",
|
finalPrompt,
|
||||||
n: 1,
|
settings!.geminiModel ?? undefined,
|
||||||
response_format: "b64_json",
|
);
|
||||||
});
|
} 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;
|
try {
|
||||||
if (!b64) return { error: "No image data returned from DALL-E" };
|
// 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}`;
|
const b64 = response.data?.[0]?.b64_json;
|
||||||
await ctx.db.project.update({ where: { id: params.projectId }, data: { coverImageUrl } });
|
if (!b64) return { error: "No image data returned from DALL-E" };
|
||||||
|
coverImageUrl = `data:image/png;base64,${b64}`;
|
||||||
return {
|
} catch (err) {
|
||||||
__action: "invalidate",
|
return { error: `DALL-E error: ${parseAiError(err)}` };
|
||||||
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)}` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async remove_project_cover(params: { projectId: string }, ctx: ToolContext) {
|
||||||
@@ -5339,6 +5428,162 @@ const executors = {
|
|||||||
body: updated.body.slice(0, 100),
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Executor ───────────────────────────────────────────────────────────────
|
||||||
@@ -5383,16 +5628,19 @@ export async function executeTool(
|
|||||||
|
|
||||||
if (actionType === "invalidate") {
|
if (actionType === "invalidate") {
|
||||||
const scope = actionResult.scope as string[];
|
const scope = actionResult.scope as string[];
|
||||||
// Strip __action and scope from the result sent back to the AI
|
// Strip __action, scope, and large data from the result sent back to the AI
|
||||||
const { __action: _, scope: _s, ...rest } = actionResult;
|
const { __action: _, scope: _s, coverImageUrl: _img, ...rest } = actionResult;
|
||||||
|
const content = JSON.stringify(rest);
|
||||||
return {
|
return {
|
||||||
content: JSON.stringify(rest),
|
content: content.length > 4000 ? content.slice(0, 4000) + '..."' : content,
|
||||||
action: { type: "invalidate", scope },
|
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) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
return { content: JSON.stringify({ error: msg }) };
|
return { content: JSON.stringify({ error: msg }) };
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from
|
|||||||
|
|
||||||
const MAX_TOOL_ITERATIONS = 8;
|
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:
|
Deine Fähigkeiten:
|
||||||
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur, Rollen, Blueprints, Rate Cards beantworten
|
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur, Rollen, Blueprints, Rate Cards beantworten
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||||
|
import { createAuditEntry } from "../lib/audit.js";
|
||||||
|
|
||||||
export const blueprintRouter = createTRPCRouter({
|
export const blueprintRouter = createTRPCRouter({
|
||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
@@ -35,7 +36,7 @@ export const blueprintRouter = createTRPCRouter({
|
|||||||
create: adminProcedure
|
create: adminProcedure
|
||||||
.input(CreateBlueprintSchema)
|
.input(CreateBlueprintSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return ctx.db.blueprint.create({
|
const blueprint = await ctx.db.blueprint.create({
|
||||||
data: {
|
data: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
target: input.target,
|
target: input.target,
|
||||||
@@ -45,17 +46,30 @@ export const blueprintRouter = createTRPCRouter({
|
|||||||
validationRules: input.validationRules as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
validationRules: input.validationRules as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
||||||
} as unknown as Parameters<typeof ctx.db.blueprint.create>[0]["data"],
|
} 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
|
update: adminProcedure
|
||||||
.input(z.object({ id: z.string(), data: UpdateBlueprintSchema }))
|
.input(z.object({ id: z.string(), data: UpdateBlueprintSchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await findUniqueOrThrow(
|
const before = await findUniqueOrThrow(
|
||||||
ctx.db.blueprint.findUnique({ where: { id: input.id } }),
|
ctx.db.blueprint.findUnique({ where: { id: input.id } }),
|
||||||
"Blueprint",
|
"Blueprint",
|
||||||
);
|
);
|
||||||
|
|
||||||
return ctx.db.blueprint.update({
|
const updated = await ctx.db.blueprint.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: {
|
data: {
|
||||||
...(input.data.name !== undefined ? { name: input.data.name } : {}),
|
...(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 } : {}),
|
...(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"],
|
} 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 */
|
/** Dedicated mutation for saving role presets — separate from field defs to avoid Zod depth issues */
|
||||||
updateRolePresets: adminProcedure
|
updateRolePresets: adminProcedure
|
||||||
.input(z.object({ id: z.string(), rolePresets: z.array(z.unknown()) }))
|
.input(z.object({ id: z.string(), rolePresets: z.array(z.unknown()) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await findUniqueOrThrow(
|
const before = await findUniqueOrThrow(
|
||||||
ctx.db.blueprint.findUnique({ where: { id: input.id } }),
|
ctx.db.blueprint.findUnique({ where: { id: input.id } }),
|
||||||
"Blueprint",
|
"Blueprint",
|
||||||
);
|
);
|
||||||
return ctx.db.blueprint.update({
|
const updated = await ctx.db.blueprint.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: { rolePresets: input.rolePresets as unknown as import("@planarchy/db").Prisma.InputJsonValue },
|
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
|
delete: adminProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// Soft delete — mark as inactive
|
// Soft delete — mark as inactive
|
||||||
return ctx.db.blueprint.update({
|
const deleted = await ctx.db.blueprint.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: { isActive: false },
|
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
|
batchDelete: adminProcedure
|
||||||
@@ -100,6 +155,19 @@ export const blueprintRouter = createTRPCRouter({
|
|||||||
ctx.db.blueprint.update({ where: { id }, data: { isActive: false } }),
|
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 };
|
return { count: updated.length };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -122,9 +190,23 @@ export const blueprintRouter = createTRPCRouter({
|
|||||||
setGlobal: adminProcedure
|
setGlobal: adminProcedure
|
||||||
.input(z.object({ id: z.string(), isGlobal: z.boolean() }))
|
.input(z.object({ id: z.string(), isGlobal: z.boolean() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return ctx.db.blueprint.update({
|
const updated = await ctx.db.blueprint.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: { isGlobal: input.isGlobal },
|
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
Reference in New Issue
Block a user