chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,5 @@
import { BlueprintsClient } from "~/components/blueprints/BlueprintsClient.js";
export default function BlueprintsPage() {
return <BlueprintsClient />;
}
@@ -0,0 +1,5 @@
import { ClientsAdminClient } from "~/components/admin/ClientsAdminClient.js";
export default function ClientsPage() {
return <ClientsAdminClient />;
}
@@ -0,0 +1,5 @@
import { CountriesClient } from "~/components/admin/CountriesClient.js";
export default function CountriesPage() {
return <CountriesClient />;
}
@@ -0,0 +1,5 @@
import { EffortRulesClient } from "~/components/admin/EffortRulesClient.js";
export default function EffortRulesPage() {
return <EffortRulesClient />;
}
@@ -0,0 +1,5 @@
import { ExperienceMultipliersClient } from "~/components/admin/ExperienceMultipliersClient.js";
export default function ExperienceMultipliersPage() {
return <ExperienceMultipliersClient />;
}
@@ -0,0 +1,5 @@
import { ManagementLevelsClient } from "~/components/admin/ManagementLevelsClient.js";
export default function ManagementLevelsPage() {
return <ManagementLevelsClient />;
}
@@ -0,0 +1,5 @@
import { OrgUnitsClient } from "~/components/admin/OrgUnitsClient.js";
export default function OrgUnitsPage() {
return <OrgUnitsClient />;
}
@@ -0,0 +1,5 @@
import { RateCardsClient } from "~/components/admin/RateCardsClient.js";
export default function RateCardsPage() {
return <RateCardsClient />;
}
@@ -0,0 +1,5 @@
import { SystemSettingsClient } from "~/components/admin/SystemSettingsClient.js";
export default function AdminSettingsPage() {
return <SystemSettingsClient />;
}
@@ -0,0 +1,5 @@
import { BatchSkillImport } from "~/components/admin/BatchSkillImport.js";
export default function BatchSkillImportPage() {
return <BatchSkillImport />;
}
@@ -0,0 +1,5 @@
import { UsersClient } from "~/components/admin/UsersClient.js";
export default function UsersPage() {
return <UsersClient />;
}
@@ -0,0 +1,5 @@
import { UtilizationCategoriesClient } from "~/components/admin/UtilizationCategoriesClient.js";
export default function UtilizationCategoriesPage() {
return <UtilizationCategoriesClient />;
}
@@ -0,0 +1,17 @@
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
export const metadata = { title: "Vacation Management — Planarchy" };
export default function AdminVacationsPage() {
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
<p className="text-sm text-gray-500 mt-1">Manage public holidays, entitlements, and year summaries</p>
</div>
<PublicHolidayBatch />
<EntitlementManager />
</div>
);
}
@@ -0,0 +1,46 @@
export default function AllocationsLoading() {
return (
<div className="flex flex-col h-full gap-4 animate-pulse">
{/* Header */}
<div className="flex items-center justify-between">
<div className="h-7 w-36 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-9 w-32 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
{/* Filter bar */}
<div className="flex gap-2">
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
</div>
{/* Table */}
<div className="flex-1 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
{/* Header */}
<div className="flex gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="h-3 w-4 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-28 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 flex-1 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
</div>
{/* Rows */}
{[...Array(10)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800">
<div className="h-4 w-4 bg-gray-100 dark:bg-gray-700 rounded" />
<div className="h-3 w-28 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-3 w-16 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-5 w-20 bg-gray-100 dark:bg-gray-800 rounded-full" />
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,5 @@
import { AllocationsClient } from "~/components/allocations/AllocationsClient.js";
export default function AllocationsPage() {
return <AllocationsClient />;
}
@@ -0,0 +1,5 @@
import { SkillsAnalytics } from "~/components/analytics/SkillsAnalytics.js";
export default function SkillsAnalyticsPage() {
return <SkillsAnalytics />;
}
@@ -0,0 +1,5 @@
import { DashboardClient } from "~/components/dashboard/DashboardClient.js";
export default function DashboardPage() {
return <DashboardClient />;
}
@@ -0,0 +1,400 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { AppRouter } from "@planarchy/api/router";
import { EstimateStatus, type EstimateVersionStatus } from "@planarchy/shared";
import type { inferRouterOutputs } from "@trpc/server";
import { clsx } from "clsx";
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { formatDateLong } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
type RouterOutput = inferRouterOutputs<AppRouter>;
type EstimateListItem = RouterOutput["estimate"]["list"][number];
type EstimateDetail = RouterOutput["estimate"]["getById"];
const STATUS_STYLES: Record<EstimateStatus, string> = {
DRAFT: "bg-slate-100 text-slate-700",
IN_REVIEW: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
ARCHIVED: "bg-zinc-200 text-zinc-700",
};
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
WORKING: "bg-sky-100 text-sky-700",
BASELINE: "bg-violet-100 text-violet-700",
SUBMITTED: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
SUPERSEDED: "bg-zinc-200 text-zinc-700",
};
function formatMoney(cents: number | null | undefined, currency = "EUR") {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format((cents ?? 0) / 100);
}
function formatMetricValue(metric: EstimateDetail["versions"][number]["metrics"][number]) {
if (metric.valueCents != null) {
return formatMoney(metric.valueCents, metric.currency ?? "EUR");
}
if (metric.key === "margin_percent") {
return `${metric.valueDecimal.toFixed(0)}%`;
}
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 }).format(metric.valueDecimal);
}
function getLatestVersion(estimate: EstimateDetail | null | undefined) {
if (!estimate) return null;
return [...estimate.versions].sort((left, right) => right.versionNumber - left.versionNumber)[0] ?? null;
}
function EstimateDetailPanel({
estimate,
onClone,
cloning,
}: {
estimate: EstimateDetail;
onClone?: (id: string) => void;
cloning?: boolean;
}) {
const latestVersion = getLatestVersion(estimate);
const latestMetrics = latestVersion?.metrics ?? [];
return (
<aside className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">Estimate detail</p>
<h2 className="mt-2 text-xl font-semibold text-gray-900">{estimate.name}</h2>
<p className="mt-1 text-sm text-gray-500">
{estimate.project ? `${estimate.project.shortCode} - ${estimate.project.name}` : "Standalone estimate"}
</p>
</div>
<span className={clsx("rounded-full px-3 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}>
{estimate.status.replace("_", " ")}
</span>
</div>
<div className="mt-4 flex gap-2">
<Link
href={`/estimates/${estimate.id}`}
className="inline-flex items-center justify-center rounded-2xl border border-brand-200 bg-brand-50 px-4 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-100"
>
Open workspace
</Link>
{onClone && (
<button
type="button"
disabled={cloning}
onClick={() => onClone(estimate.id)}
className="inline-flex items-center justify-center rounded-2xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-gray-300 hover:bg-gray-50 disabled:opacity-50"
>
{cloning ? "Cloning..." : "Clone"}
</button>
)}
</div>
{latestVersion ? (
<>
<div className="mt-5 flex items-center gap-2">
<span className="text-sm font-medium text-gray-700">
Version {latestVersion.versionNumber}
{latestVersion.label ? ` - ${latestVersion.label}` : ""}
</span>
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", VERSION_STYLES[latestVersion.status])}>
{latestVersion.status}
</span>
</div>
{latestMetrics.length > 0 && (
<div className="mt-5 grid gap-3 sm:grid-cols-2">
{latestMetrics.map((metric) => (
<div key={metric.id} className="rounded-2xl border border-gray-100 bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</p>
<p className="mt-1 text-lg font-semibold text-gray-900">{formatMetricValue(metric)}</p>
</div>
))}
</div>
)}
{latestVersion.notes && (
<div className="mt-5 rounded-2xl border border-gray-100 bg-gray-50 p-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Version notes</p>
<p className="mt-2 text-sm text-gray-700">{latestVersion.notes}</p>
</div>
)}
<div className="mt-5 grid gap-5 xl:grid-cols-2">
<section>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900">Scope items</h3>
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
</div>
<div className="mt-3 space-y-2">
{latestVersion.scopeItems.length === 0 ? (
<p className="rounded-2xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-400">
No scope rows captured yet.
</p>
) : (
latestVersion.scopeItems.map((item) => (
<div key={item.id} className="rounded-2xl border border-gray-100 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<p className="font-medium text-gray-900">{item.name}</p>
<span className="text-xs text-gray-400">{item.scopeType}</span>
</div>
{item.description && <p className="mt-1 text-sm text-gray-600">{item.description}</p>}
</div>
))
)}
</div>
</section>
<section>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900">Demand lines</h3>
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
</div>
<div className="mt-3 space-y-2">
{latestVersion.demandLines.length === 0 ? (
<p className="rounded-2xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-400">
No staffing demand captured yet.
</p>
) : (
latestVersion.demandLines.map((line) => (
<div key={line.id} className="rounded-2xl border border-gray-100 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<p className="font-medium text-gray-900">{line.name}</p>
<p className="text-sm font-medium text-gray-600">{line.hours.toFixed(1)} h</p>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500">
<span>{formatMoney(line.costTotalCents, line.currency)} cost</span>
<span>{formatMoney(line.priceTotalCents, line.currency)} sell</span>
{line.chapter && <span>{line.chapter}</span>}
</div>
</div>
))
)}
</div>
</section>
</div>
</>
) : (
<p className="mt-6 rounded-2xl border border-dashed border-gray-200 px-4 py-6 text-sm text-gray-400">
No versions available for this estimate yet.
</p>
)}
</aside>
);
}
function EstimateCard({
estimate,
active,
onSelect,
canInspect,
}: {
estimate: EstimateListItem;
active: boolean;
onSelect: () => void;
canInspect: boolean;
}) {
const latestVersion = estimate.versions[0];
return (
<button
type="button"
onClick={onSelect}
disabled={!canInspect}
className={clsx(
"w-full rounded-3xl border p-5 text-left transition",
active ? "border-brand-500 bg-brand-50 shadow-sm" : "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm",
!canInspect && "cursor-default",
)}
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}>
{estimate.status.replace("_", " ")}
</span>
{estimate.project && (
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600">
{estimate.project.shortCode}
</span>
)}
</div>
<h3 className="mt-3 text-lg font-semibold text-gray-900">{estimate.name}</h3>
<p className="mt-1 text-sm text-gray-500">
{estimate.project ? estimate.project.name : "No linked project"}
</p>
</div>
{latestVersion && (
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[latestVersion.status])}>
v{latestVersion.versionNumber}
</span>
)}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity</p>
<p className="mt-1 text-sm text-gray-700">{estimate.opportunityId ?? "Not set"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
<p className="mt-1 text-sm text-gray-700">{formatDateLong(estimate.updatedAt)}</p>
</div>
</div>
{!canInspect && (
<p className="mt-4 text-xs text-gray-400">
Detailed financial breakdown is limited to manager and controller roles.
</p>
)}
</button>
);
}
export function EstimatesClient() {
const [search, setSearch] = useState("");
const [status, setStatus] = useState<EstimateStatus | "">("");
const [wizardOpen, setWizardOpen] = useState(false);
const [selectedEstimateId, setSelectedEstimateId] = useState<string | null>(null);
const { canEdit, canViewCosts } = usePermissions();
const utils = trpc.useUtils();
const cloneMutation = trpc.estimate.clone.useMutation({
onSuccess: (cloned) => {
void utils.estimate.list.invalidate();
setSelectedEstimateId(cloned.id);
},
});
const listQuery = trpc.estimate.list.useQuery(
{
query: search || undefined,
status: status || undefined,
},
{ staleTime: 15_000 },
);
const detailQuery = trpc.estimate.getById.useQuery(
{ id: selectedEstimateId ?? "" },
{
enabled: canViewCosts && !!selectedEstimateId,
staleTime: 15_000,
},
);
const estimates = listQuery.data ?? [];
const selectedEstimate = useMemo(() => {
if (!canViewCosts) return null;
return detailQuery.data ?? null;
}, [canViewCosts, detailQuery.data]);
return (
<>
<div className="space-y-6">
<div className="rounded-[28px] border border-gray-200 bg-gradient-to-br from-white via-white to-brand-50 p-6 shadow-sm">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimating</p>
<h1 className="mt-2 text-3xl font-semibold text-gray-900">Browser-native estimate workspace</h1>
<p className="mt-2 max-w-3xl text-sm text-gray-600">
Build structured estimates from live projects, resources, and role data instead of maintaining a disconnected spreadsheet.
</p>
</div>
{canEdit && (
<button
type="button"
onClick={() => setWizardOpen(true)}
className="inline-flex items-center justify-center rounded-2xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700"
>
New Estimate
</button>
)}
</div>
<div className="mt-6 grid gap-3 lg:grid-cols-[minmax(0,1fr),220px]">
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search by estimate or opportunity"
className="w-full rounded-2xl border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 outline-none ring-0 transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
/>
<select
value={status}
onChange={(event) => setStatus(event.target.value as EstimateStatus | "")}
className="w-full rounded-2xl border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
>
<option value="">All statuses</option>
{Object.values(EstimateStatus).map((value) => (
<option key={value} value={value}>
{value.replace("_", " ")}
</option>
))}
</select>
</div>
</div>
{listQuery.isLoading ? (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-14 text-center text-sm text-gray-400">
Loading estimates...
</div>
) : estimates.length === 0 ? (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-14 text-center">
<p className="text-base font-medium text-gray-700">No estimates yet</p>
<p className="mt-2 text-sm text-gray-400">
Start with the wizard to create a connected estimate from Planarchy data.
</p>
</div>
) : (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(320px,0.95fr)]">
<div className="space-y-4">
{estimates.map((estimate) => (
<EstimateCard
key={estimate.id}
estimate={estimate}
active={estimate.id === selectedEstimateId}
canInspect={canViewCosts}
onSelect={() => {
if (!canViewCosts) return;
setSelectedEstimateId((current) => (current === estimate.id ? current : estimate.id));
}}
/>
))}
</div>
<div>
{canViewCosts ? (
selectedEstimate ? (
<EstimateDetailPanel
estimate={selectedEstimate}
{...(canEdit ? { onClone: (id: string) => cloneMutation.mutate({ sourceEstimateId: id }), cloning: cloneMutation.isPending } : {})}
/>
) : (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-14 text-center text-sm text-gray-400">
Select an estimate to inspect the current version, demand lines, and summary metrics.
</div>
)
) : (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-14 text-center text-sm text-gray-400">
Your role can access the estimate list, but not the detailed financial breakdown.
</div>
)}
</div>
</div>
)}
</div>
{wizardOpen && <EstimateWizard onClose={() => setWizardOpen(false)} />}
</>
);
}
@@ -0,0 +1,10 @@
import { EstimateWorkspaceClient } from "~/components/estimates/EstimateWorkspaceClient.js";
interface EstimateWorkspacePageProps {
params: Promise<{ id: string }>;
}
export default async function EstimateWorkspacePage({ params }: EstimateWorkspacePageProps) {
const { id } = await params;
return <EstimateWorkspaceClient estimateId={id} />;
}
@@ -0,0 +1,9 @@
import { EstimatesClient } from "./EstimatesClient.js";
export default function EstimatesPage() {
return (
<div className="p-6">
<EstimatesClient />
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { redirect } from "next/navigation";
import { AppShell } from "~/components/layout/AppShell.js";
import { auth } from "~/server/auth.js";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session) {
redirect("/auth/signin");
}
const userRole = (session.user as { role?: string }).role ?? "USER";
return <AppShell userRole={userRole}>{children}</AppShell>;
}
+15
View File
@@ -0,0 +1,15 @@
export default function AppLoading() {
return (
<div className="p-6 space-y-4 animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" />
<div className="h-4 bg-gray-100 dark:bg-gray-800 rounded w-72" />
<div className="grid grid-cols-4 gap-4 mt-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-20 bg-gray-100 dark:bg-gray-800 rounded-xl" />
))}
</div>
<div className="h-64 bg-gray-100 dark:bg-gray-800 rounded-xl" />
<div className="h-48 bg-gray-100 dark:bg-gray-800 rounded-xl" />
</div>
);
}
@@ -0,0 +1,619 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { formatDate } from "~/lib/format.js";
import type { Project, ColumnDef } from "@planarchy/shared";
import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared";
import Link from "next/link";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
import { ProjectModal } from "~/components/projects/ProjectModal.js";
import { ProjectWizard } from "~/components/projects/ProjectWizard.js";
import { useSelection } from "~/hooks/useSelection.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { FilterBar } from "~/components/ui/FilterBar.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
import { ColumnTogglePanel } from "~/components/ui/ColumnTogglePanel.js";
import { InfiniteScrollSentinel } from "~/components/ui/InfiniteScrollSentinel.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { useRowOrder } from "~/hooks/useRowOrder.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
// ─── Constants ────────────────────────────────────────────────────────────────
const STATUS_COLORS: Record<string, string> = {
DRAFT: "bg-gray-100 text-gray-700",
ACTIVE: "bg-green-100 text-green-700",
ON_HOLD: "bg-yellow-100 text-yellow-700",
COMPLETED: "bg-blue-100 text-blue-700",
CANCELLED: "bg-red-100 text-red-700",
};
const ORDER_TYPE_COLORS: Record<string, string> = {
BD: "bg-purple-100 text-purple-700",
CHARGEABLE: "bg-green-100 text-green-700",
INTERNAL: "bg-blue-100 text-blue-700",
OVERHEAD: "bg-gray-100 text-gray-700",
};
const ALL_STATUSES = [
{ value: "DRAFT", label: "Draft" },
{ value: "ACTIVE", label: "Active" },
{ value: "ON_HOLD", label: "On Hold" },
{ value: "COMPLETED", label: "Completed" },
{ value: "CANCELLED", label: "Cancelled" },
] as const;
const ALL_ORDER_TYPES = [
{ value: "BD", label: "BD" },
{ value: "CHARGEABLE", label: "Chargeable" },
{ value: "INTERNAL", label: "Internal" },
{ value: "OVERHEAD", label: "Overhead" },
] as const;
// ─── Sub-components ───────────────────────────────────────────────────────────
function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: number; budgetCents: number }) {
if (budgetCents === 0) {
return <div className="text-xs text-gray-400">No budget</div>;
}
const cappedPercent = Math.min(utilizationPercent, 100);
let barColor = "bg-green-500";
if (utilizationPercent > 95) barColor = "bg-red-500";
else if (utilizationPercent > 85) barColor = "bg-orange-500";
else if (utilizationPercent > 70) barColor = "bg-yellow-500";
return (
<div className="space-y-0.5 min-w-[80px]">
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden w-full">
<div className={clsx("h-full rounded-full transition-all", barColor)} style={{ width: `${cappedPercent}%` }} />
</div>
<div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div>
</div>
);
}
function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: ProjectRow; isOpen: boolean; onOpen: () => void; onClose: () => void }) {
const utils = trpc.useUtils();
const dropdownRef = useRef<HTMLDivElement>(null);
const updateStatus = trpc.project.updateStatus.useMutation({
onSuccess: async () => {
await utils.project.listWithCosts.invalidate();
onClose();
},
});
useEffect(() => {
if (!isOpen) return;
function handleOutsideClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) onClose();
}
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, [isOpen, onClose]);
return (
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={(e) => { e.stopPropagation(); isOpen ? onClose() : onOpen(); }}
className={clsx(
"inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full cursor-pointer transition-opacity hover:opacity-80",
STATUS_COLORS[project.status] ?? "bg-gray-100 text-gray-700",
)}
title="Click to change status"
>
{project.status}
<svg className="w-2.5 h-2.5 opacity-60" 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>
{isOpen && (
<div className="absolute left-0 top-full mt-1 z-20 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-[130px]">
{ALL_STATUSES.map((s) => (
<button
key={s.value}
type="button"
disabled={s.value === project.status || updateStatus.isPending}
onClick={(e) => { e.stopPropagation(); updateStatus.mutate({ id: project.id, status: s.value as never }); }}
className={clsx(
"w-full text-left px-3 py-1.5 text-xs transition-colors",
s.value === project.status
? "font-semibold text-gray-400 cursor-default"
: "text-gray-700 hover:bg-gray-50 cursor-pointer",
)}
>
<span className={clsx("inline-block px-1.5 py-0.5 rounded-full", STATUS_COLORS[s.value] ?? "bg-gray-100 text-gray-700")}>
{s.label}
</span>
</button>
))}
</div>
)}
</div>
);
}
// ─── Types ────────────────────────────────────────────────────────────────────
interface ProjectRow {
id: string;
shortCode: string;
name: string;
status: string;
orderType: string;
startDate: string | Date;
endDate: string | Date;
budgetCents: number;
winProbability: number;
totalCostCents: number;
totalPersonDays: number;
utilizationPercent: number;
dynamicFields?: Record<string, unknown> | null;
}
// ─── Main component ───────────────────────────────────────────────────────────
export function ProjectsClient() {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("");
const [orderTypeFilter, setOrderTypeFilter] = useState<string>("");
const [modalOpen, setModalOpen] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [openStatusProjectId, setOpenStatusProjectId] = useState<string | null>(null);
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
const selection = useSelection();
const utils = trpc.useUtils();
const { canViewCosts } = usePermissions();
const batchUpdateStatus = trpc.project.batchUpdateStatus.useMutation({
onSuccess: async () => {
await utils.project.listWithCosts.invalidate();
selection.clear();
},
});
// ─── Custom field columns from global blueprints ──────────────────────────
const { data: globalFieldDefs } = trpc.blueprint.getGlobalFieldDefs.useQuery(
{ target: BlueprintTarget.PROJECT },
{ staleTime: 300_000 },
);
const customColumns = useMemo<ColumnDef[]>(
() =>
(globalFieldDefs ?? [])
.filter((f) => f.showInList)
.map((f) => ({
key: `custom_${f.key}`,
label: f.label,
defaultVisible: false,
hideable: true,
isCustom: true,
fieldType: f.type as string,
})),
[globalFieldDefs],
);
// ─── Column visibility ────────────────────────────────────────────────────
// Filter out budget column if user cannot view costs
const baseColumns = useMemo<ColumnDef[]>(
() => (canViewCosts ? PROJECT_COLUMNS : PROJECT_COLUMNS.filter((c) => c.key !== "budget")),
[canViewCosts],
);
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig(
"projects",
baseColumns,
customColumns,
);
const defaultKeys = useMemo(
() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key),
[baseColumns],
);
// ─── Infinite query (cursor-based) ────────────────────────────────────────
const {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = trpc.project.listWithCosts.useInfiniteQuery(
{
search: search || undefined,
status: (statusFilter as ProjectStatus) || undefined,
limit: 50,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
initialCursor: undefined,
placeholderData: (prev) => prev,
staleTime: 15_000,
},
);
const allProjects = useMemo(
() => (data?.pages.flatMap((p) => p.projects) ?? []) as unknown as ProjectRow[],
[data],
);
// Client-side orderType filter
const filteredProjects = useMemo(
() => (orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects),
[allProjects, orderTypeFilter],
);
// ─── Sort + row order ─────────────────────────────────────────────────────
const viewPrefs = useViewPrefs("projects");
const { sorted, sortField, sortDir, toggle, reset } = useTableSort(filteredProjects, {
initialField: viewPrefs.savedSort?.field ?? null,
initialDir: viewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
const { orderedRows: projects, reorder, isCustomOrder, resetOrder } = useRowOrder(
sorted,
viewPrefs,
sortField,
reset,
);
const rowDragRef = useRef<string | null>(null);
const projectIds = projects.map((p) => p.id);
useEffect(() => {
selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search, statusFilter, orderTypeFilter]);
const handleFetchNext = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
function openNewModal() { setEditingProject(null); setModalOpen(true); }
function openEditModal(project: Project) { setEditingProject(project); setModalOpen(true); }
function closeModal() { setModalOpen(false); setEditingProject(null); }
function clearAll() { setSearch(""); setStatusFilter(""); setOrderTypeFilter(""); }
const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []),
...(orderTypeFilter ? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }] : []),
];
// ─── Cell renderer ────────────────────────────────────────────────────────
function renderCell(col: ColumnDef, project: ProjectRow) {
const dynFields = (project.dynamicFields ?? {}) as Record<string, unknown>;
if (col.isCustom) {
const fieldKey = col.key.replace(/^custom_/, "");
const val = dynFields[fieldKey];
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600">{val != null ? String(val) : "—"}</td>;
}
switch (col.key) {
case "shortCode":
return <td key={col.key} className="px-4 py-3 text-sm font-mono font-medium text-gray-900">{project.shortCode}</td>;
case "name":
return (
<td key={col.key} className="px-4 py-3 text-sm font-medium text-gray-900 max-w-xs truncate">
<Link href={`/projects/${project.id}`} className="hover:text-brand-600 hover:underline">
{project.name}
</Link>
</td>
);
case "status":
return (
<td key={col.key} className="px-4 py-3">
<StatusDropdown
project={project}
isOpen={openStatusProjectId === project.id}
onOpen={() => setOpenStatusProjectId(project.id)}
onClose={() => setOpenStatusProjectId(null)}
/>
</td>
);
case "orderType":
return (
<td key={col.key} className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}>
{project.orderType}
</span>
</td>
);
case "dates":
return (
<td key={col.key} className="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">
{formatDate(project.startDate)} {formatDate(project.endDate)}
</td>
);
case "budget":
return (
<td key={col.key} className="px-4 py-3 min-w-[120px]">
<div className="text-sm text-gray-900 mb-0.5">
{(project.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })}
</div>
<BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} />
</td>
);
case "allocations":
return (
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 text-right">
{project.totalPersonDays > 0 ? `${project.totalPersonDays}d` : "—"}
</td>
);
case "responsible":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500"></td>;
default:
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600"></td>;
}
}
// ─── Header renderer ──────────────────────────────────────────────────────
const SORTABLE_PROJECT_COLS = new Set(["shortCode", "name", "status", "orderType", "dates", "budget", "allocations"]);
function renderHeader(col: ColumnDef) {
if (SORTABLE_PROJECT_COLS.has(col.key)) {
return (
<SortableColumnHeader
key={col.key}
label={col.label}
field={col.key}
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
/>
);
}
return (
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{col.label}
</th>
);
}
return (
<>
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Projects</h1>
{!isLoading && (
<p className="text-gray-500 text-sm mt-1">
{projects.length} project{projects.length !== 1 ? "s" : ""}
{hasNextPage ? "+" : ""}
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setWizardOpen(true)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
New Project Wizard
</button>
<button
type="button"
onClick={openNewModal}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Quick Add
</button>
</div>
</div>
{/* Filters */}
<FilterBar>
<input
type="search"
placeholder="Search projects..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
>
<option value="">All Statuses</option>
{ALL_STATUSES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<select
value={orderTypeFilter}
onChange={(e) => setOrderTypeFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
>
<option value="">All Types</option>
{ALL_ORDER_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<ColumnTogglePanel
allColumns={allColumns}
visibleKeys={visibleKeys}
onSetVisible={setVisible}
defaultKeys={defaultKeys}
/>
{isCustomOrder && (
<button
type="button"
onClick={resetOrder}
className="text-xs text-gray-500 hover:text-gray-700 underline whitespace-nowrap"
title="Clear manual row order"
>
Reset order
</button>
)}
</FilterBar>
{/* Filter chips */}
{chips.length > 0 && (
<div className="mb-3">
<FilterChips chips={chips} onClearAll={clearAll} />
</div>
)}
{/* Table */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
{isLoading ? (
<div className="py-16 text-center text-sm text-gray-400 animate-pulse">Loading projects</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{/* Drag handle column */}
<th className="w-8 px-2" />
<th className="px-4 py-3 w-10">
<input
type="checkbox"
checked={selection.isAllSelected(projectIds)}
ref={(el) => {
if (el) el.indeterminate = selection.isIndeterminate(projectIds);
}}
onChange={() => selection.toggleAll(projectIds)}
className="rounded border-gray-300"
/>
</th>
{visibleColumns.map(renderHeader)}
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{projects.map((project) => {
const isSelected = selection.selectedIds.has(project.id);
return (
<DraggableTableRow
key={project.id}
id={project.id}
dragRef={rowDragRef}
onDrop={(draggedId) => reorder(draggedId, project.id)}
className={`hover:bg-gray-50 transition-colors ${isSelected ? "bg-brand-50" : ""}`}
>
<td className="px-4 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => selection.toggle(project.id)}
className="rounded border-gray-300"
/>
</td>
{visibleColumns.map((col) => renderCell(col, project))}
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => openEditModal(project as unknown as Project)}
className="text-xs text-gray-600 hover:text-gray-900 hover:underline font-medium transition-colors"
>
Edit
</button>
<Link href={`/projects/${project.id}`} className="text-xs text-blue-600 hover:text-blue-800 hover:underline font-medium">
View
</Link>
</div>
</td>
</DraggableTableRow>
);
})}
</tbody>
</table>
</div>
{projects.length === 0 && (
<div className="text-center py-12 text-gray-500">
No projects found.{" "}
<button type="button" onClick={openNewModal} className="text-brand-600 hover:underline font-medium">
Create your first project.
</button>
</div>
)}
<InfiniteScrollSentinel
onVisible={handleFetchNext}
isLoading={isFetchingNextPage}
/>
</>
)}
</div>
{/* Batch Status Picker */}
{batchStatusPicker && (
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}>
<div className="bg-white rounded-xl shadow-2xl p-5 min-w-[220px]" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Set status for {selection.count} projects</h3>
<div className="flex flex-col gap-1">
{ALL_STATUSES.map((s) => (
<button
key={s.value}
type="button"
onClick={() => {
setConfirmBatchStatus({ ids: selection.selectedArray, status: s.value });
setBatchStatusPicker(false);
}}
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-gray-50 transition-colors"
>
<span className={clsx("inline-block px-2 py-0.5 text-xs rounded-full", STATUS_COLORS[s.value])}>
{s.label}
</span>
</button>
))}
</div>
</div>
</div>
)}
{/* Confirm batch status change */}
{confirmBatchStatus && (
<ConfirmDialog
title="Update Project Status"
message={`Set ${confirmBatchStatus.ids.length} project${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`}
confirmLabel="Update"
onConfirm={() => {
if (confirmBatchStatus) {
batchUpdateStatus.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never });
}
setConfirmBatchStatus(null);
}}
onCancel={() => setConfirmBatchStatus(null)}
/>
)}
{/* Batch Action Bar */}
<BatchActionBar
count={selection.count}
onClear={selection.clear}
actions={[
{ label: "Set Status…", onClick: () => setBatchStatusPicker(true) },
]}
/>
{/* Modal */}
{modalOpen && <ProjectModal project={editingProject} onClose={closeModal} />}
{/* Wizard */}
<ProjectWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
</>
);
}
@@ -0,0 +1,251 @@
import { notFound } from "next/navigation";
import { formatDate } from "~/lib/format.js";
import Link from "next/link";
import { createCaller } from "~/server/trpc.js";
import { BudgetStatusCard } from "~/components/projects/BudgetStatusCard.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
interface ProjectDetailPageProps {
params: Promise<{ id: string }>;
}
const STATUS_COLORS: Record<string, string> = {
DRAFT: "bg-gray-100 text-gray-700",
ACTIVE: "bg-green-100 text-green-700",
ON_HOLD: "bg-yellow-100 text-yellow-700",
COMPLETED: "bg-blue-100 text-blue-700",
CANCELLED: "bg-red-100 text-red-700",
};
const ORDER_TYPE_COLORS: Record<string, string> = {
BD: "bg-purple-100 text-purple-700",
CHARGEABLE: "bg-green-100 text-green-700",
INTERNAL: "bg-blue-100 text-blue-700",
OVERHEAD: "bg-gray-100 text-gray-700",
};
const ALLOC_STATUS_COLORS: Record<string, string> = {
ACTIVE: "bg-green-100 text-green-700",
PROPOSED: "bg-yellow-100 text-yellow-700",
CONFIRMED: "bg-blue-100 text-blue-700",
CANCELLED: "bg-gray-100 text-gray-500",
};
export default async function ProjectDetailPage({ params }: ProjectDetailPageProps) {
const { id } = await params;
const trpc = await createCaller();
let project: Awaited<ReturnType<typeof trpc.project.getById>>;
try {
project = await trpc.project.getById({ id });
} catch {
notFound();
}
const activeAssignments = project.assignments.filter((assignment) => assignment.status !== "CANCELLED");
const activeDemands = project.demands.filter((demand) => demand.status !== "CANCELLED");
const requestedSeats = activeDemands.reduce((sum, demand) => sum + demand.requestedHeadcount, 0);
const unfilledSeats = activeDemands.reduce((sum, demand) => sum + demand.unfilledHeadcount, 0);
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Back link */}
<Link
href="/projects"
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-800 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Projects
</Link>
{/* Project header */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="font-mono text-sm font-medium text-gray-500">{project.shortCode}</span>
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[project.status] ?? ""}`}>
{project.status}
</span>
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}>
{project.orderType}
</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">{project.name}</h1>
</div>
<div className="text-right text-sm text-gray-500 flex-shrink-0">
<div className="font-medium text-gray-800">
{formatDate(project.startDate)}
{" — "}
{formatDate(project.endDate)}
</div>
<div className="mt-0.5">Win probability: {project.winProbability}%</div>
</div>
</div>
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-4 mt-4 pt-4 border-t border-gray-100">
<div>
<dt className="text-xs text-gray-500">Chargecode</dt>
<dd className="mt-0.5 text-sm font-mono font-medium text-gray-900">{project.shortCode}</dd>
</div>
<div>
<dt className="text-xs text-gray-500">Order Type</dt>
<dd className="mt-0.5 text-sm text-gray-900">{project.orderType}</dd>
</div>
<div>
<dt className="text-xs text-gray-500">Allocation Type</dt>
<dd className="mt-0.5 text-sm text-gray-900">{project.allocationType}</dd>
</div>
<div>
<dt className="text-xs text-gray-500">Assignments</dt>
<dd className="mt-0.5 text-sm text-gray-900">{activeAssignments.length} active</dd>
</div>
<div>
<dt className="text-xs text-gray-500">Open Demands</dt>
<dd className="mt-0.5 text-sm text-gray-900">
{activeDemands.length} items · {unfilledSeats}/{requestedSeats} seats unfilled
</dd>
</div>
{project.responsiblePerson && (
<div className="sm:col-span-2">
<dt className="text-xs text-gray-500">Responsible Person</dt>
<dd className="mt-0.5 text-sm font-medium text-gray-900">{project.responsiblePerson}</dd>
</div>
)}
</dl>
</div>
{/* Budget status card (client component) */}
<BudgetStatusCard projectId={project.id} />
{/* Assignments table */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wider">
Assignments ({project.assignments.length})
</h2>
</div>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Resource</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role <InfoTooltip content="Role this allocation was created for." />
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Period <InfoTooltip content="Start and end date of the allocation." />
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Hours/Day <InfoTooltip content="Planned working hours per calendar day." />
</span>
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Daily Cost <InfoTooltip content="Resource LCR × hours per day." />
</span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status <InfoTooltip content="PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed." />
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{project.assignments.map((assignment) => (
<tr key={assignment.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3 text-sm font-medium text-gray-900">
{assignment.resource?.displayName ?? "—"}
{assignment.resource?.eid && (
<span className="ml-1.5 text-xs text-gray-400 font-mono">{assignment.resource.eid}</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600">{assignment.role || "—"}</td>
<td className="px-4 py-3 text-xs text-gray-500">
{formatDate(assignment.startDate)}
{" → "}
{formatDate(assignment.endDate)}
</td>
<td className="px-4 py-3 text-sm text-gray-900">{assignment.hoursPerDay}h</td>
<td className="px-4 py-3 text-sm text-gray-900">
{(assignment.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 text-xs rounded-full ${ALLOC_STATUS_COLORS[assignment.status] ?? "bg-gray-100 text-gray-600"}`}
>
{assignment.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
{project.assignments.length === 0 && (
<div className="text-center py-12 text-gray-500 text-sm">No assignments for this project.</div>
)}
</div>
{/* Open demands table */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wider">
Open Demands ({project.demands.length})
</h2>
</div>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Period
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Requested
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Unfilled
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Hours/Day
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{project.demands.map((demand) => (
<tr key={demand.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3 text-sm text-gray-900">
{demand.roleEntity?.name ?? demand.role ?? "Unassigned"}
</td>
<td className="px-4 py-3 text-xs text-gray-500">
{formatDate(demand.startDate)}
{" → "}
{formatDate(demand.endDate)}
</td>
<td className="px-4 py-3 text-sm text-right text-gray-900">{demand.requestedHeadcount}</td>
<td className="px-4 py-3 text-sm text-right text-gray-900">{demand.unfilledHeadcount}</td>
<td className="px-4 py-3 text-sm text-right text-gray-900">{demand.hoursPerDay}h</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 text-xs rounded-full ${ALLOC_STATUS_COLORS[demand.status] ?? "bg-gray-100 text-gray-600"}`}
>
{demand.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
{project.demands.length === 0 && (
<div className="text-center py-12 text-gray-500 text-sm">No open demands for this project.</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,51 @@
export default function ProjectsLoading() {
return (
<div className="flex flex-col h-full gap-4 animate-pulse">
{/* Header */}
<div className="flex items-center justify-between">
<div className="h-7 w-28 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-9 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
{/* Filter bar */}
<div className="flex gap-2">
<div className="h-9 flex-1 bg-gray-100 dark:bg-gray-800 rounded-lg" />
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
<div className="h-9 w-32 bg-gray-100 dark:bg-gray-800 rounded-lg" />
</div>
{/* Table */}
<div className="flex-1 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
{/* Header */}
<div className="flex gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="h-3 w-4 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 flex-1 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-12 bg-gray-300 dark:bg-gray-600 rounded" />
</div>
{/* Rows */}
{[...Array(10)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800">
<div className="h-4 w-4 bg-gray-100 dark:bg-gray-700 rounded" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-5 w-20 bg-gray-100 dark:bg-gray-800 rounded-full" />
<div className="h-5 w-16 bg-gray-100 dark:bg-gray-800 rounded-full" />
<div className="flex flex-col gap-1 w-24">
<div className="h-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-2 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
</div>
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-3 w-8 bg-gray-100 dark:bg-gray-800 rounded" />
</div>
))}
</div>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { ProjectsClient } from "./ProjectsClient.js";
export default function ProjectsPage() {
return (
<div className="p-6">
<ProjectsClient />
</div>
);
}
@@ -0,0 +1,5 @@
import { ChargeabilityReportClient } from "~/components/reports/ChargeabilityReportClient.js";
export default function ChargeabilityReportPage() {
return <ChargeabilityReportClient />;
}
@@ -0,0 +1,530 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import Link from "next/link";
import type { Resource, SkillEntry } from "@planarchy/shared";
import { RESOURCE_COLUMNS } from "@planarchy/shared";
import { BlueprintTarget } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { ResourceModal } from "~/components/resources/ResourceModal.js";
import { ImportModal } from "~/components/resources/ImportModal.js";
import { BulkEditModal } from "~/components/resources/BulkEditModal.js";
import { useSelection } from "~/hooks/useSelection.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { FilterBar } from "~/components/ui/FilterBar.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
import { CustomFieldFilterBar } from "~/components/ui/CustomFieldFilterBar.js";
import { useFilters } from "~/hooks/useFilters.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ColumnTogglePanel } from "~/components/ui/ColumnTogglePanel.js";
import { InfiniteScrollSentinel } from "~/components/ui/InfiniteScrollSentinel.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { useRowOrder } from "~/hooks/useRowOrder.js";
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
type ModalState =
| { type: "closed" }
| { type: "create" }
| { type: "edit"; resource: Resource }
| { type: "import" }
| { type: "bulkEdit" };
type ConfirmState =
| { type: "closed" }
| { type: "batchDeactivate"; ids: string[] }
| { type: "deactivate"; resource: Resource };
type ActiveFilter = "active" | "inactive" | "all";
type ResourceListPage = {
resources: Resource[];
total: number;
nextCursor?: string | null;
};
export function ResourcesClient() {
const [search, setSearch] = useState("");
const [chapterFilter, setChapterFilter] = useState("");
const [isActiveFilter, setIsActiveFilter] = useState<ActiveFilter>("active");
const [modal, setModal] = useState<ModalState>({ type: "closed" });
const [confirm, setConfirm] = useState<ConfirmState>({ type: "closed" });
const selection = useSelection();
const utils = trpc.useUtils();
const { canViewScores, canViewCosts } = usePermissions();
const { customFieldFilters, setCustomFieldFilter, clearFilters: clearCustomFilters } = useFilters();
// ─── Custom field columns from global blueprints ──────────────────────────
const { data: globalFieldDefs } = trpc.blueprint.getGlobalFieldDefs.useQuery(
{ target: BlueprintTarget.RESOURCE },
{ staleTime: 300_000 },
);
const customColumns = useMemo(
() =>
(globalFieldDefs ?? [])
.filter((f) => f.showInList)
.map((f) => ({
key: `custom_${f.key}`,
label: f.label,
defaultVisible: false,
hideable: true,
isCustom: true,
fieldType: f.type as string,
})),
[globalFieldDefs],
);
const filterableFields = useMemo(
() => (globalFieldDefs ?? []).filter((f) => f.isFilterable),
[globalFieldDefs],
);
// ─── Column visibility ────────────────────────────────────────────────────
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig(
"resources",
RESOURCE_COLUMNS,
customColumns,
);
const defaultKeys = useMemo(
() => RESOURCE_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key),
[],
);
// ─── Infinite query (cursor-based) ────────────────────────────────────────
const {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
// Keep this boundary shallow; the full TRPC inference here trips TS depth limits.
} = (trpc.resource.list.useInfiniteQuery as any)(
{
isActive: isActiveFilter === "all" ? undefined : isActiveFilter === "active",
search: search || undefined,
chapter: chapterFilter || undefined,
includeRoles: true,
limit: 50,
...(customFieldFilters.length > 0 ? { customFieldFilters } : {}),
},
{
getNextPageParam: (lastPage: ResourceListPage) => lastPage.nextCursor ?? undefined,
initialCursor: undefined,
placeholderData: (prev: { pages: ResourceListPage[] } | undefined) => prev,
staleTime: 20_000,
},
) as {
data:
| {
pages: ResourceListPage[];
}
| undefined;
isLoading: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => Promise<unknown>;
hasNextPage: boolean | undefined;
};
const resources = useMemo(
() => (data?.pages.flatMap((p) => p.resources) ?? []) as unknown as Resource[],
[data],
);
const total = data?.pages[0]?.total ?? 0;
// ─── Sort + row order (per-user persistence) ──────────────────────────────
const viewPrefs = useViewPrefs("resources");
const { sorted, sortField, sortDir, toggle, reset } = useTableSort<Resource>(resources, {
initialField: viewPrefs.savedSort?.field ?? null,
initialDir: viewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
const { orderedRows: displayedResources, reorder, isCustomOrder, resetOrder } = useRowOrder(
sorted,
viewPrefs,
sortField,
reset,
);
const rowDragRef = useRef<string | null>(null);
const resourceIds: string[] = displayedResources.map((r) => r.id);
// Performance note: cursor-based infinite scroll (50 rows/page) keeps DOM nodes bounded.
// True virtualizer is not needed for typical resource counts (<500).
// ─── Chargeability stats ──────────────────────────────────────────────────
const { data: chargeabilityData } = trpc.resource.getChargeabilityStats.useQuery(
{},
{ enabled: canViewCosts, placeholderData: (prev) => prev, staleTime: 60_000 },
);
const chargeabilityMap = useMemo(
() => new Map((chargeabilityData ?? []).map((s) => [s.id, s])),
[chargeabilityData],
);
// ─── Chapters filter ──────────────────────────────────────────────────────
const { data: chapterData } = trpc.resource.chapters.useQuery(
undefined,
{ placeholderData: (prev) => prev, staleTime: 60_000 },
);
const chapters = chapterData ?? [];
// ─── Mutations ────────────────────────────────────────────────────────────
const deactivateMutation = trpc.resource.deactivate.useMutation({
onSuccess: async () => { await utils.resource.list.invalidate(); },
});
const batchDeactivateMutation = trpc.resource.batchDeactivate.useMutation({
onSuccess: async () => {
await utils.resource.list.invalidate();
selection.clear();
},
});
useEffect(() => {
selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search, chapterFilter, isActiveFilter]);
function closeModal() { setModal({ type: "closed" }); }
function handleConfirm() {
if (confirm.type === "deactivate") {
deactivateMutation.mutate({ id: confirm.resource.id });
} else if (confirm.type === "batchDeactivate") {
batchDeactivateMutation.mutate({ ids: confirm.ids });
}
setConfirm({ type: "closed" });
}
function clearAll() {
setSearch("");
setChapterFilter("");
setIsActiveFilter("active");
clearCustomFilters();
}
const handleFetchNext = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(chapterFilter ? [{ label: `Chapter: ${chapterFilter}`, onRemove: () => setChapterFilter("") }] : []),
...(isActiveFilter !== "active" ? [{ label: isActiveFilter === "all" ? "Showing all" : "Inactive only", onRemove: () => setIsActiveFilter("active") }] : []),
...customFieldFilters.map((f) => ({
label: `${f.key}: ${f.value}`,
onRemove: () => setCustomFieldFilter(f.key, "", f.type),
})),
];
return (
<div className="p-6 pb-24">
{/* Page header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Resources</h1>
{!isLoading && (
<p className="text-gray-500 text-sm mt-1">{total} resource{total !== 1 ? "s" : ""}</p>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setModal({ type: "import" })}
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
Import
</button>
<button
type="button"
onClick={() => setModal({ type: "create" })}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></svg>
New Resource
</button>
</div>
</div>
{/* Filters + Column toggle */}
<FilterBar>
<input
type="search"
placeholder="Search by name, EID, email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm"
/>
{chapters.length > 0 && (
<select
value={chapterFilter}
onChange={(e) => setChapterFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
>
<option value="">All Chapters</option>
{chapters.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
)}
<select
value={isActiveFilter}
onChange={(e) => setIsActiveFilter(e.target.value as ActiveFilter)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
>
<option value="active">Active only</option>
<option value="inactive">Inactive only</option>
<option value="all">All resources</option>
</select>
<ColumnTogglePanel
allColumns={allColumns}
visibleKeys={visibleKeys}
onSetVisible={setVisible}
defaultKeys={defaultKeys}
/>
{isCustomOrder && (
<button
type="button"
onClick={resetOrder}
className="text-xs text-gray-500 hover:text-gray-700 underline whitespace-nowrap"
title="Clear manual row order"
>
Reset order
</button>
)}
</FilterBar>
{filterableFields.length > 0 && (
<div className="mb-2">
<CustomFieldFilterBar
filterableFields={filterableFields}
activeFilters={customFieldFilters}
onSetFilter={setCustomFieldFilter}
/>
</div>
)}
{chips.length > 0 && (
<div className="mb-3">
<FilterChips chips={chips} onClearAll={clearAll} />
</div>
)}
{/* Table */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
{isLoading && resources.length === 0 ? (
<div className="p-12 text-center text-gray-400 text-sm animate-pulse">Loading resources</div>
) : (
<>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{/* Drag handle column */}
<th className="w-8 px-2" />
<th className="px-4 py-3 w-10">
<input
type="checkbox"
checked={selection.isAllSelected(resourceIds)}
ref={(el) => { if (el) el.indeterminate = selection.isIndeterminate(resourceIds); }}
onChange={() => selection.toggleAll(resourceIds)}
className="rounded border-gray-300"
/>
</th>
{visibleColumns.map((col) => {
if (col.isCustom) {
return (
<th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{col.label}
</th>
);
}
switch (col.key) {
case "eid":
return <SortableColumnHeader key={col.key} label="EID" field="eid" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Unique employee identifier used across all Planarchy records." />;
case "displayName":
return <SortableColumnHeader key={col.key} label="Name / Email" field="displayName" sortField={sortField} sortDir={sortDir} onSort={toggle} />;
case "chapter":
return <SortableColumnHeader key={col.key} label="Chapter" field="chapter" sortField={sortField} sortDir={sortDir} onSort={toggle} />;
case "lcr":
return <SortableColumnHeader key={col.key} label="LCR (€/h)" field="lcrCents" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Labour Cost Rate — the resource's hourly cost in EUR. Used to calculate project budgets (LCR × hours/day × working days)." />;
case "chargeability":
return <SortableColumnHeader key={col.key} label="Chargeability (actual)" field="chargeabilityTarget" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Actual = CONFIRMED+ACTIVE allocations on non-DRAFT projects ÷ available working hours this month × 100. Expected (in parentheses) includes DRAFT projects. Target is the management-set goal." tooltipWidth="w-80" />;
case "valueScore":
return canViewScores
? <SortableColumnHeader key={col.key} label="Score" field="valueScore" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Composite price/quality score 0100. Weights: Skill Depth 30% · Cost Efficiency 25% · Skill Breadth 15% · Chargeability 15% · Experience 15%. Recompute in Admin → Settings." tooltipWidth="w-72" />
: null;
case "roles":
return <th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roles <InfoTooltip content="Primary role (★) and additional roles assigned to this resource. Used for open demand and staffing suggestions." /></th>;
case "isActive":
return <th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Skills <InfoTooltip content="Skills from the resource's skill matrix. Shows first 3; hover the +N badge for more." /></th>;
default:
return <th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{col.label}</th>;
}
})}
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{displayedResources.map((resource) => {
const skills = resource.skills as unknown as SkillEntry[];
const isSelected = selection.selectedIds.has(resource.id);
const isDeactivating =
deactivateMutation.isPending &&
(deactivateMutation.variables as { id: string } | undefined)?.id === resource.id;
const dynFields = (resource as unknown as { dynamicFields?: Record<string, unknown> }).dynamicFields ?? {};
return (
<DraggableTableRow
key={resource.id}
id={resource.id}
dragRef={rowDragRef}
onDrop={(draggedId) => reorder(draggedId, resource.id)}
className={`hover:bg-gray-50 transition-colors ${isSelected ? "bg-brand-50" : ""}`}
>
<td className="px-4 py-3">
<input type="checkbox" checked={isSelected} onChange={() => selection.toggle(resource.id)} className="rounded border-gray-300" />
</td>
{visibleColumns.map((col) => {
if (col.isCustom) {
const fieldKey = col.key.replace(/^custom_/, "");
const val = dynFields[fieldKey];
return <td key={col.key} className="px-3 py-3 text-sm text-gray-700">{val != null ? String(val) : "—"}</td>;
}
switch (col.key) {
case "eid":
return <td key={col.key} className="px-4 py-3 text-sm font-mono text-gray-600">{resource.eid}</td>;
case "displayName":
return (
<td key={col.key} className="px-4 py-3">
<Link href={`/resources/${resource.id}`} className="text-sm font-medium text-gray-900 hover:text-brand-600 hover:underline transition-colors">{resource.displayName}</Link>
<div className="text-xs text-gray-500">{resource.email}</div>
</td>
);
case "chapter":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600">{resource.chapter ?? "—"}</td>;
case "lcr":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900">{(resource.lcrCents / 100).toFixed(0)} {resource.currency}</td>;
case "chargeability": {
if (!canViewCosts) return <td key={col.key} className="px-4 py-3 text-sm text-gray-500">{resource.chargeabilityTarget}%</td>;
const stats = chargeabilityMap.get(resource.id);
const actual = stats?.actualChargeability;
const expected = stats?.expectedChargeability;
const target = resource.chargeabilityTarget;
const color = actual == null ? "text-gray-400" : actual >= target ? "text-green-700" : actual >= target - 20 ? "text-amber-600" : "text-red-600";
return (
<td key={col.key} className="px-4 py-3 text-sm">
<div>
<span className={`font-medium ${color}`}>{actual != null ? `${actual}%` : "—"}</span>
{expected != null && expected !== actual && <span className="text-xs text-gray-400 ml-1">({expected}% exp.)</span>}
<div className="text-xs text-gray-400">Target: {target}%</div>
</div>
</td>
);
}
case "valueScore": {
if (!canViewScores) return null;
const score = (resource as unknown as { valueScore?: number | null }).valueScore;
return (
<td key={col.key} className="px-4 py-3 text-sm">
{score != null ? (
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${score >= 70 ? "bg-green-100 text-green-700" : score >= 40 ? "bg-amber-100 text-amber-700" : "bg-red-100 text-red-700"}`}>{score}</span>
) : (
<span className="text-gray-400 text-xs"></span>
)}
</td>
);
}
case "roles": {
const rr = ((resource as unknown as { resourceRoles?: { isPrimary: boolean; role: { id: string; name: string; color: string | null } }[] }).resourceRoles ?? []);
return (
<td key={col.key} className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{rr.map((r) => (
<span key={r.role.id} className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full font-medium" style={{ backgroundColor: `${r.role.color ?? "#6366f1"}22`, color: r.role.color ?? "#6366f1" }}>
{r.isPrimary && <span className="text-[10px]"></span>}
{r.role.name}
</span>
))}
{rr.length === 0 && <span className="text-xs text-gray-400"></span>}
</div>
</td>
);
}
case "isActive":
return (
<td key={col.key} className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{skills.slice(0, 3).map((s) => (
<span key={s.skill} className="inline-block px-2 py-0.5 text-xs bg-brand-50 text-brand-700 rounded-full">{s.skill}</span>
))}
{skills.length > 3 && <span className="text-xs text-gray-400">+{skills.length - 3}</span>}
</div>
</td>
);
default:
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600"></td>;
}
})}
<td className="px-4 py-3 text-right whitespace-nowrap">
<button type="button" onClick={() => setModal({ type: "edit", resource: resource as unknown as Resource })} className="text-xs font-medium text-brand-600 hover:text-brand-800 transition-colors mr-3">Edit</button>
<button type="button" onClick={() => setConfirm({ type: "deactivate", resource: resource as unknown as Resource })} disabled={isDeactivating} className="text-xs font-medium text-red-600 hover:text-red-800 transition-colors disabled:opacity-50">{isDeactivating ? "Deactivating…" : "Deactivate"}</button>
</td>
</DraggableTableRow>
);
})}
</tbody>
</table>
{displayedResources.length === 0 && !isLoading && (
<div className="text-center py-12 text-gray-500 text-sm">No resources found.</div>
)}
{/* Infinite scroll trigger */}
<InfiniteScrollSentinel onVisible={handleFetchNext} isLoading={isFetchingNextPage} />
</>
)}
</div>
<BatchActionBar
count={selection.count}
onClear={selection.clear}
actions={[
...(filterableFields.length > 0 ? [{
label: "Edit Custom Fields",
variant: "default" as const,
onClick: () => setModal({ type: "bulkEdit" }),
disabled: false,
}] : []),
{
label: `Deactivate ${selection.count > 0 ? `(${selection.count})` : ""}`,
variant: "danger" as const,
onClick: () => setConfirm({ type: "batchDeactivate", ids: selection.selectedArray }),
disabled: batchDeactivateMutation.isPending,
},
]}
/>
{modal.type === "create" && <ResourceModal mode="create" onClose={closeModal} />}
{modal.type === "edit" && <ResourceModal mode="edit" resource={modal.resource} onClose={closeModal} />}
{modal.type === "import" && <ImportModal onClose={closeModal} />}
{modal.type === "bulkEdit" && (
<BulkEditModal
selectedIds={selection.selectedArray}
fieldDefs={filterableFields}
onClose={closeModal}
onSuccess={selection.clear}
/>
)}
{confirm.type === "deactivate" && (
<ConfirmDialog title="Deactivate Resource" message={`Deactivate "${confirm.resource.displayName}" (${confirm.resource.eid})? This will remove them from the active resource list.`} confirmLabel="Deactivate" variant="danger" onConfirm={handleConfirm} onCancel={() => setConfirm({ type: "closed" })} />
)}
{confirm.type === "batchDeactivate" && (
<ConfirmDialog title="Deactivate Resources" message={`Deactivate ${confirm.ids.length} selected resource${confirm.ids.length !== 1 ? "s" : ""}?`} confirmLabel="Deactivate All" variant="danger" onConfirm={handleConfirm} onCancel={() => setConfirm({ type: "closed" })} />
)}
</div>
);
}
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { createCaller } from "~/server/trpc.js";
import { ResourceDetail } from "~/components/resources/ResourceDetail.js";
export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> },
): Promise<Metadata> {
const { id } = await params;
try {
const trpc = await createCaller();
const resource = await trpc.resource.getById({ id });
return { title: `${resource.displayName} — Resources | Planarchy` };
} catch {
return { title: "Resource — Planarchy" };
}
}
export default async function ResourceDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <ResourceDetail resourceId={id} />;
}
@@ -0,0 +1,52 @@
export default function ResourcesLoading() {
return (
<div className="flex flex-col h-full gap-4 animate-pulse">
{/* Page header */}
<div className="flex items-center justify-between">
<div className="h-7 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-9 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
{/* Filter bar */}
<div className="flex gap-2">
<div className="h-9 flex-1 bg-gray-100 dark:bg-gray-800 rounded-lg" />
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
<div className="h-9 w-28 bg-gray-100 dark:bg-gray-800 rounded-lg" />
</div>
{/* Table */}
<div className="flex-1 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
{/* Header */}
<div className="flex gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="h-3 w-4 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-10 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-28 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-14 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 flex-1 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
</div>
{/* Rows */}
{[...Array(10)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800">
<div className="h-4 w-4 bg-gray-100 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
<div className="h-3 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-3 w-14 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-5 w-20 bg-gray-100 dark:bg-gray-800 rounded-full" />
<div className="h-5 w-12 bg-gray-100 dark:bg-gray-800 rounded-full" />
<div className="flex gap-1 flex-1">
<div className="h-5 w-16 bg-gray-100 dark:bg-gray-800 rounded-full" />
<div className="h-5 w-16 bg-gray-100 dark:bg-gray-800 rounded-full" />
</div>
<div className="h-3 w-16 bg-gray-100 dark:bg-gray-800 rounded" />
</div>
))}
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import { ResourcesClient } from "./ResourcesClient.js";
export default function ResourcesPage() {
return (
<Suspense>
<ResourcesClient />
</Suspense>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { RolesClient } from "~/components/roles/RolesClient.js";
export default function RolesPage() {
return <RolesClient />;
}
+13
View File
@@ -0,0 +1,13 @@
import { StaffingPanel } from "~/components/staffing/StaffingPanel.js";
export default function StaffingPage() {
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Staffing Suggestions</h1>
<p className="text-gray-500 text-sm mt-1">Find the best resource match for your project needs</p>
</div>
<StaffingPanel />
</div>
);
}
@@ -0,0 +1,53 @@
export default function TimelineLoading() {
return (
<div className="flex flex-col h-full gap-0 animate-pulse">
{/* Toolbar */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div className="h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded-lg" />
<div className="h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded-lg" />
<div className="flex-1" />
<div className="h-8 w-8 bg-gray-100 dark:bg-gray-800 rounded-lg" />
<div className="h-8 w-8 bg-gray-100 dark:bg-gray-800 rounded-lg" />
<div className="h-8 w-20 bg-gray-100 dark:bg-gray-800 rounded-lg" />
</div>
{/* Date header */}
<div className="flex border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<div className="w-48 flex-shrink-0 px-4 py-2">
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
<div className="flex-1 flex gap-px py-2 px-2">
{[...Array(20)].map((_, i) => (
<div key={i} className="flex-1 h-3 bg-gray-200 dark:bg-gray-700 rounded" />
))}
</div>
</div>
{/* Resource rows */}
{[...Array(8)].map((_, i) => (
<div key={i} className="flex border-b border-gray-100 dark:border-gray-800 py-3">
{/* Resource name cell */}
<div className="w-48 flex-shrink-0 px-4 flex flex-col gap-1.5">
<div className="h-3 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-2 w-12 bg-gray-100 dark:bg-gray-800 rounded" />
</div>
{/* Allocation bars */}
<div className="flex-1 relative px-2 flex items-center gap-1">
{i % 3 === 0 && (
<div className="h-7 rounded-lg bg-brand-100 dark:bg-brand-900/30" style={{ width: "35%", marginLeft: "10%" }} />
)}
{i % 3 === 1 && (
<>
<div className="h-7 rounded-lg bg-purple-100 dark:bg-purple-900/30" style={{ width: "20%", marginLeft: "5%" }} />
<div className="h-7 rounded-lg bg-blue-100 dark:bg-blue-900/30" style={{ width: "30%", marginLeft: "2%" }} />
</>
)}
{i % 3 === 2 && (
<div className="h-7 rounded-lg bg-green-100 dark:bg-green-900/30" style={{ width: "45%", marginLeft: "20%" }} />
)}
</div>
</div>
))}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { TimelineView } from "~/components/timeline/TimelineView.js";
export default function TimelinePage() {
return (
<div className="h-full flex flex-col">
<div className="p-6 pb-0">
<h1 className="text-2xl font-bold text-gray-900">Timeline</h1>
<p className="text-gray-500 text-sm mt-1">Interactive resource planning timeline</p>
</div>
<TimelineView />
</div>
);
}
@@ -0,0 +1,7 @@
import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js";
export const metadata = { title: "My Vacations — Planarchy" };
export default function MyVacationsPage() {
return <MyVacationsClient />;
}
@@ -0,0 +1,5 @@
import { VacationClient } from "~/components/vacations/VacationClient.js";
export default function VacationsPage() {
return <VacationClient />;
}
@@ -0,0 +1,3 @@
import { handlers } from "~/server/auth.js";
export const { GET, POST } = handlers;
@@ -0,0 +1,105 @@
import { renderToBuffer } from "@react-pdf/renderer";
import { createElement } from "react";
import { NextResponse } from "next/server";
import * as XLSX from "xlsx";
import { buildSplitAllocationReadModel } from "@planarchy/application";
import { prisma } from "@planarchy/db";
import type { AllocationLike } from "@planarchy/shared";
import { auth } from "~/server/auth.js";
import { AllocationReport } from "~/components/reports/AllocationReport.js";
export async function GET(request: Request) {
const session = await auth();
if (!session?.user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { searchParams } = new URL(request.url);
const startDate = searchParams.get("startDate") ? new Date(searchParams.get("startDate")!) : new Date();
const endDate = searchParams.get("endDate") ? new Date(searchParams.get("endDate")!) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
const format = searchParams.get("format") ?? "pdf";
const [demandRequirements, assignments] = await Promise.all([
prisma.demandRequirement.findMany({
where: {
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
},
include: {
project: { select: { id: true, name: true, shortCode: true } },
},
orderBy: [{ project: { name: "asc" } }, { startDate: "asc" }],
take: 1000,
}),
prisma.assignment.findMany({
where: {
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
},
include: {
resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } },
project: { select: { id: true, name: true, shortCode: true } },
},
orderBy: [{ project: { name: "asc" } }, { startDate: "asc" }],
take: 1000,
}),
]);
const allocationView = buildSplitAllocationReadModel({
demandRequirements,
assignments,
});
const assignmentRows = allocationView.assignments.slice(0, 500);
const rows = assignmentRows.map((a: AllocationLike & {
resource?: { displayName?: string | null } | null;
project?: { shortCode: string; name: string } | null;
}) => ({
resourceName: a.resource?.displayName ?? "Unknown",
projectName: a.project ? `${a.project.shortCode}${a.project.name}` : "Unknown project",
role: a.role ?? "",
startDate: new Date(a.startDate).toLocaleDateString("en-GB"),
endDate: new Date(a.endDate).toLocaleDateString("en-GB"),
hoursPerDay: a.hoursPerDay,
dailyCostCents: a.dailyCostCents,
}));
const ts = Date.now();
if (format === "xlsx") {
const sheetData = rows.map((r: typeof rows[number]) => ({
Resource: r.resourceName,
Project: r.projectName,
Role: r.role,
"Start Date": r.startDate,
"End Date": r.endDate,
"Hours/Day": r.hoursPerDay,
"Daily Cost (ct)": r.dailyCostCents,
}));
const ws = XLSX.utils.json_to_sheet(sheetData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Allocations");
const buffer = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer;
return new NextResponse(buffer as unknown as BodyInit, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="allocations-${ts}.xlsx"`,
},
});
}
const title = `Allocation Report ${startDate.toLocaleDateString("en-GB")} ${endDate.toLocaleDateString("en-GB")}`;
const generatedAt = new Date().toLocaleString("en-GB");
const doc = createElement(AllocationReport, { title, generatedAt, rows });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const buffer = await renderToBuffer(doc as any);
return new NextResponse(buffer as unknown as BodyInit, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="allocations-${ts}.pdf"`,
},
});
}
@@ -0,0 +1,61 @@
import { eventBus } from "@planarchy/api/sse";
import { SSE_EVENT_TYPES } from "@planarchy/shared";
import { auth } from "~/server/auth.js";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET() {
const session = await auth();
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// Send initial connection confirmation
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`),
);
// Subscribe to event bus
const unsubscribe = eventBus.subscribe((event) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
} catch {
// Client disconnected
}
});
// Heartbeat every 30 seconds
const heartbeat = setInterval(() => {
try {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`),
);
} catch {
clearInterval(heartbeat);
unsubscribe();
}
}, 30000);
// Cleanup on close
return () => {
clearInterval(heartbeat);
unsubscribe();
};
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { createTRPCContext } from "@planarchy/api";
import { appRouter } from "@planarchy/api/router";
import { prisma } from "@planarchy/db";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import type { NextRequest } from "next/server";
import { auth } from "~/server/auth.js";
const handler = async (req: NextRequest) => {
const session = await auth();
const dbUser = session?.user?.email
? await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true, systemRole: true, permissionOverrides: true },
})
: null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options: any = {
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createTRPCContext({ session, dbUser }),
};
if (process.env["NODE_ENV"] === "development") {
options.onError = ({ path, error }: { path?: string; error: { message: string } }) => {
console.error(`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`);
};
}
return fetchRequestHandler(options);
};
export { handler as GET, handler as POST };
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function SignInPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
setError("Invalid email or password");
} else {
router.push("/dashboard");
}
setLoading(false);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-brand-50 to-brand-100">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Planarchy</h1>
<p className="text-gray-500 mt-2">Resource Planning & Staffing</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
placeholder="admin@planarchy.dev"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-brand-600 hover:bg-brand-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
<p className="text-xs text-gray-500 font-medium mb-2">Demo accounts:</p>
<div className="space-y-1 text-xs text-gray-600">
<p><span className="font-mono">admin@planarchy.dev</span> / admin123 (Admin)</p>
<p><span className="font-mono">manager@planarchy.dev</span> / manager123 (Manager)</p>
<p><span className="font-mono">viewer@planarchy.dev</span> / viewer123 (Viewer)</p>
</div>
</div>
</div>
</div>
</div>
);
}
+265
View File
@@ -0,0 +1,265 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ─── Accent Color CSS Variables ────────────────────────────────────────────
Each data-accent value sets the --accent-* RGB triplets consumed by Tailwind
brand-* classes. The format is "R G B" (no commas) for Tailwind opacity support.
*/
/* Sky Blue (default) */
:root,
[data-accent="sky"] {
--accent-50: 240 249 255;
--accent-100: 224 242 254;
--accent-200: 186 230 253;
--accent-300: 125 211 252;
--accent-400: 56 189 248;
--accent-500: 14 165 233;
--accent-600: 2 132 199;
--accent-700: 3 105 161;
--accent-800: 7 89 133;
--accent-900: 12 74 110;
}
[data-accent="indigo"] {
--accent-50: 238 242 255;
--accent-100: 224 231 255;
--accent-200: 199 210 254;
--accent-300: 165 180 252;
--accent-400: 129 140 248;
--accent-500: 99 102 241;
--accent-600: 79 70 229;
--accent-700: 67 56 202;
--accent-800: 55 48 163;
--accent-900: 49 46 129;
}
[data-accent="violet"] {
--accent-50: 245 243 255;
--accent-100: 237 233 254;
--accent-200: 221 214 254;
--accent-300: 196 181 253;
--accent-400: 167 139 250;
--accent-500: 139 92 246;
--accent-600: 124 58 237;
--accent-700: 109 40 217;
--accent-800: 91 33 182;
--accent-900: 76 29 149;
}
[data-accent="emerald"] {
--accent-50: 236 253 245;
--accent-100: 209 250 229;
--accent-200: 167 243 208;
--accent-300: 110 231 183;
--accent-400: 52 211 153;
--accent-500: 16 185 129;
--accent-600: 5 150 105;
--accent-700: 4 120 87;
--accent-800: 6 95 70;
--accent-900: 6 78 59;
}
[data-accent="rose"] {
--accent-50: 255 241 242;
--accent-100: 255 228 230;
--accent-200: 254 205 211;
--accent-300: 253 164 175;
--accent-400: 251 113 133;
--accent-500: 244 63 94;
--accent-600: 225 29 72;
--accent-700: 190 18 60;
--accent-800: 159 18 57;
--accent-900: 136 19 55;
}
[data-accent="amber"] {
--accent-50: 255 251 235;
--accent-100: 254 243 199;
--accent-200: 253 230 138;
--accent-300: 252 211 77;
--accent-400: 251 191 36;
--accent-500: 245 158 11;
--accent-600: 217 119 6;
--accent-700: 180 83 9;
--accent-800: 146 64 14;
--accent-900: 120 53 15;
}
/* ─── Light Theme Surface Variables ─────────────────────────────────────── */
:root {
--surface-page: 249 250 251; /* gray-50 */
--surface-card: 255 255 255; /* white */
--surface-elevated: 249 250 251; /* gray-50 */
--surface-input: 255 255 255; /* white */
--border-subtle: 229 231 235; /* gray-200 */
--border-input: 209 213 219; /* gray-300 */
--text-primary: 17 24 39; /* gray-900 */
--text-secondary: 75 85 99; /* gray-600 */
--text-muted: 107 114 128; /* gray-500 */
--text-very-muted: 156 163 175; /* gray-400 */
}
/* ─── Dark Theme Surface Variables ──────────────────────────────────────── */
.dark {
color-scheme: dark;
--surface-page: 10 11 16; /* near-black page bg */
--surface-card: 24 27 38; /* dark card bg */
--surface-elevated: 32 36 50; /* slightly lighter - table headers etc */
--surface-input: 32 36 50; /* input bg */
--border-subtle: 45 51 71; /* dark border */
--border-input: 58 65 90; /* slightly lighter border */
--text-primary: 232 234 240; /* near-white */
--text-secondary: 163 173 197; /* medium gray */
--text-muted: 107 117 142; /* muted */
--text-very-muted: 75 84 107; /* very muted */
}
/* ─── Base Layer: Apply variables to body ────────────────────────────────── */
@layer base {
body {
background-color: rgb(var(--surface-page));
color: rgb(var(--text-primary));
transition: background-color 0.15s ease, color 0.15s ease;
}
/* Smooth transition for theme changes */
*, *::before, *::after {
transition-property: background-color, border-color, color;
transition-duration: 0.1s;
transition-timing-function: ease;
}
/* Scrollbar styling for dark mode */
.dark ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark ::-webkit-scrollbar-track {
background: rgb(var(--surface-card));
}
.dark ::-webkit-scrollbar-thumb {
background: rgb(var(--border-subtle));
border-radius: 4px;
}
}
/* ─── Dark Mode Overrides for Common Tailwind Patterns ──────────────────── */
/* These override the most commonly used utility classes in the app */
.dark .bg-white {
background-color: rgb(var(--surface-card)) !important;
}
.dark .bg-gray-50 {
background-color: rgb(var(--surface-elevated)) !important;
}
.dark .bg-gray-100 {
background-color: rgb(45 51 71) !important;
}
.dark .border-gray-100 {
border-color: rgb(var(--border-subtle)) !important;
}
.dark .border-gray-200 {
border-color: rgb(var(--border-subtle)) !important;
}
.dark .border-gray-300 {
border-color: rgb(var(--border-input)) !important;
}
.dark .text-gray-900 {
color: rgb(var(--text-primary)) !important;
}
.dark .text-gray-800 {
color: rgb(var(--text-primary)) !important;
}
.dark .text-gray-700 {
color: rgb(var(--text-secondary)) !important;
}
.dark .text-gray-600 {
color: rgb(var(--text-secondary)) !important;
}
.dark .text-gray-500 {
color: rgb(var(--text-muted)) !important;
}
.dark .text-gray-400 {
color: rgb(var(--text-very-muted)) !important;
}
.dark input,
.dark select,
.dark textarea {
background-color: rgb(var(--surface-input));
border-color: rgb(var(--border-input));
color: rgb(var(--text-primary));
}
.dark input::placeholder,
.dark textarea::placeholder {
color: rgb(var(--text-muted));
}
/* Table alternating / hover */
.dark .hover\:bg-gray-50:hover {
background-color: rgb(var(--surface-elevated)) !important;
}
.dark .divide-gray-100 > * + * {
border-color: rgb(var(--border-subtle)) !important;
}
/* Status badge adjustments in dark mode - keep them readable */
.dark .bg-green-100 { background-color: rgb(6 78 59 / 0.4) !important; }
.dark .text-green-700 { color: rgb(52 211 153) !important; }
.dark .bg-yellow-100 { background-color: rgb(120 53 15 / 0.4) !important; }
.dark .text-yellow-700 { color: rgb(251 191 36) !important; }
.dark .bg-blue-100 { background-color: rgb(30 58 138 / 0.4) !important; }
.dark .text-blue-700 { color: rgb(96 165 250) !important; }
.dark .bg-red-100 { background-color: rgb(127 29 29 / 0.4) !important; }
.dark .text-red-600 { color: rgb(248 113 113) !important; }
.dark .text-red-700 { color: rgb(248 113 113) !important; }
.dark .bg-gray-100 { background-color: rgb(var(--surface-elevated)) !important; }
.dark .text-gray-700 { color: rgb(var(--text-secondary)) !important; }
.dark .bg-purple-100 { background-color: rgb(76 29 149 / 0.4) !important; }
.dark .text-purple-700 { color: rgb(196 181 253) !important; }
.dark .bg-amber-50 { background-color: rgb(120 53 15 / 0.2) !important; }
/* Modal / overlay */
.dark .shadow-2xl {
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.6) !important;
}
/* ─── Timeline utilities (unchanged) ────────────────────────────────────── */
@layer utilities {
.timeline-row {
@apply flex border-b border-gray-100 hover:bg-gray-50/50;
min-height: 48px;
}
.allocation-block {
@apply absolute rounded-md text-xs font-medium px-2 py-1 cursor-pointer select-none;
@apply transition-all duration-150 ease-in-out;
}
.allocation-block:hover {
@apply ring-2 ring-white ring-offset-1;
}
.allocation-block.dragging {
@apply opacity-75 shadow-lg scale-105;
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { TRPCProvider } from "~/lib/trpc/provider.js";
import "./globals.css";
export const metadata: Metadata = {
title: "Planarchy — Resource Planning",
description: "Interactive resource planning and project staffing tool",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{__html: `
try {
var p = JSON.parse(localStorage.getItem('planarchy_theme') || '{}');
if (p.mode === 'dark') document.documentElement.classList.add('dark');
if (p.accent) document.documentElement.setAttribute('data-accent', p.accent);
} catch(e) {}
`}} />
</head>
<body className="min-h-screen bg-gray-50 font-sans antialiased">
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
import { auth } from "~/server/auth.js";
export default async function HomePage() {
const session = await auth();
if (!session) {
redirect("/auth/signin");
}
redirect("/resources");
}
@@ -0,0 +1,243 @@
"use client";
import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
import type { SkillEntry } from "@planarchy/shared";
interface ParsedEntry {
fileName: string;
candidateEid: string; // guessed from filename (no extension, lowercased)
selectedEid: string;
skills: SkillEntry[];
employeeInfo: Record<string, string>;
matchedRoleName: string | null;
status: "pending" | "matched" | "unmatched";
}
export function BatchSkillImport() {
const [entries, setEntries] = useState<ParsedEntry[]>([]);
const [result, setResult] = useState<{ updated: number; notFound: number } | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const { data: roles } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const batchMutation = trpc.resource.batchImportSkillMatrices.useMutation({
onSuccess: (data) => { setResult(data); setSubmitting(false); },
onError: (err) => { setError(err.message); setSubmitting(false); },
});
async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
setResult(null);
setError(null);
const roleNames = (roles ?? []).map((r) => r.name);
const resourceList = (resources?.resources ?? []) as SimpleResource[];
const parsed: ParsedEntry[] = await Promise.all(
files.map(async (file) => {
const baseName = file.name.replace(/\.[^.]+$/, "");
// Guess EID: try matching against resource displayName or eid
const candidateEid = baseName.toLowerCase().replace(/\s+/g, ".");
const matchedResource = resourceList.find(
(r) =>
r.eid.toLowerCase() === candidateEid ||
r.displayName.toLowerCase().replace(/\s+/g, ".") === candidateEid ||
r.displayName.toLowerCase() === baseName.toLowerCase(),
);
try {
const buffer = await file.arrayBuffer();
const result = parseSkillMatrixWorkbook(buffer);
let roleId: string | undefined;
let matchedRoleName: string | undefined;
if (result.employeeInfo.areaOfExpertise) {
const matched = matchRoleName(result.employeeInfo.areaOfExpertise, roleNames);
if (matched) {
const role = (roles ?? []).find((r) => r.name === matched);
roleId = role?.id;
matchedRoleName = matched;
}
}
const empInfo: Record<string, string> = {};
if (roleId) empInfo["roleId"] = roleId;
if (result.employeeInfo.portfolioUrl) empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
return {
fileName: file.name,
candidateEid,
selectedEid: matchedResource?.eid ?? candidateEid,
skills: result.skills,
employeeInfo: empInfo,
matchedRoleName: matchedRoleName ?? null,
status: matchedResource ? "matched" : "unmatched",
} satisfies ParsedEntry;
} catch {
return {
fileName: file.name,
candidateEid,
selectedEid: matchedResource?.eid ?? "",
skills: [],
employeeInfo: {},
matchedRoleName: null,
status: "unmatched",
} satisfies ParsedEntry;
}
}),
);
setEntries(parsed);
}
function updateEid(idx: number, eid: string) {
setEntries((prev) =>
prev.map((e, i) =>
i === idx
? {
...e,
selectedEid: eid,
status: eid ? "matched" : "unmatched",
}
: e,
),
);
}
function handleImport() {
const toImport = entries.filter((e) => e.selectedEid && e.skills.length > 0);
if (toImport.length === 0) return;
setSubmitting(true);
batchMutation.mutate({
entries: toImport.map((e) => ({
eid: e.selectedEid,
skills: e.skills,
employeeInfo: {
...(e.employeeInfo["roleId"] ? { roleId: e.employeeInfo["roleId"] } : {}),
...(e.employeeInfo["portfolioUrl"] ? { portfolioUrl: e.employeeInfo["portfolioUrl"] } : {}),
},
})),
});
}
type SimpleResource = { eid: string; displayName: string };
const resourceList = (resources?.resources ?? []) as SimpleResource[];
const matched = entries.filter((e) => e.status === "matched").length;
const unmatched = entries.filter((e) => e.status === "unmatched").length;
return (
<div className="p-6 max-w-4xl">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Batch Skill Matrix Import</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Upload multiple skill matrix files at once. Files are matched to resources by filename.
</p>
</div>
{/* Upload area */}
<div
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors mb-6 bg-white dark:bg-gray-800"
onClick={() => fileRef.current?.click()}
>
<svg className="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Click to select multiple .xlsx files</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Name files after resource EID or display name for automatic matching</p>
<input ref={fileRef} type="file" accept=".xlsx,.xls" multiple className="hidden" onChange={handleFiles} />
</div>
{/* Summary */}
{entries.length > 0 && (
<div className="flex gap-4 mb-4">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-lg px-4 py-2 text-sm">
<span className="font-semibold text-green-700 dark:text-green-400">{matched}</span>
<span className="text-green-600 dark:text-green-400 ml-1">matched</span>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg px-4 py-2 text-sm">
<span className="font-semibold text-yellow-700 dark:text-yellow-400">{unmatched}</span>
<span className="text-yellow-600 dark:text-yellow-400 ml-1">unmatched (select EID manually)</span>
</div>
</div>
)}
{/* Entries table */}
{entries.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">File</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Resource EID</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Skills</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Role Match</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{entries.map((entry, idx) => (
<tr key={idx} className={entry.status === "unmatched" ? "bg-yellow-50 dark:bg-yellow-900/10" : ""}>
<td className="px-4 py-3 text-xs text-gray-600 dark:text-gray-400 font-mono">{entry.fileName}</td>
<td className="px-4 py-3">
{entry.status === "matched" ? (
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">{entry.selectedEid}</span>
) : (
<select
className="w-full px-2 py-1.5 border border-yellow-300 dark:border-yellow-600 rounded text-sm bg-white dark:bg-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-500"
value={entry.selectedEid}
onChange={(e) => updateEid(idx, e.target.value)}
>
<option value=""> Select resource </option>
{resourceList.map((r) => (
<option key={r.eid} value={r.eid}>{r.displayName} ({r.eid})</option>
))}
</select>
)}
</td>
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{entry.skills.length}</td>
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{entry.matchedRoleName ?? "—"}</td>
<td className="px-4 py-3 text-center">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
entry.status === "matched" ? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400" : "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400"
}`}>
{entry.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">{error}</div>
)}
{result && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Import complete: <strong>{result.updated}</strong> updated, <strong>{result.notFound}</strong> not found.
</div>
)}
{entries.length > 0 && !result && (
<button
type="button"
onClick={handleImport}
disabled={submitting || matched === 0}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{submitting ? "Importing…" : `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
</button>
)}
</div>
);
}
@@ -0,0 +1,290 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type ClientRow = {
id: string;
name: string;
code: string | null;
parentId: string | null;
sortOrder: number;
isActive: boolean;
};
type ClientNode = ClientRow & { children: ClientNode[] };
type EditingClient = {
id?: string;
name: string;
code: string;
parentId: string;
sortOrder: number;
};
function ClientTreeNode({
node,
onEdit,
onAddChild,
depth = 0,
}: {
node: ClientNode;
onEdit: (c: ClientRow) => void;
onAddChild: (parentId: string) => void;
depth?: number;
}) {
const [expanded, setExpanded] = useState(depth < 1);
const hasChildren = node.children.length > 0;
return (
<div>
<div
className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-800/30 rounded-lg group"
style={{ paddingLeft: `${depth * 24 + 12}px` }}
>
{hasChildren ? (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 text-xs"
>
{expanded ? "▼" : "▶"}
</button>
) : (
<span className="w-5" />
)}
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 flex-1">
{node.name}
{node.code && <span className="text-gray-400 font-mono ml-1 text-xs">[{node.code}]</span>}
</span>
{!node.isActive && <span className="text-xs text-gray-400 italic">inactive</span>}
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => onAddChild(node.id)}
className="text-xs text-green-600 hover:text-green-800 font-medium"
>
+ Child
</button>
<button
type="button"
onClick={() => onEdit(node)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
</div>
</div>
{expanded && node.children.map((child) => (
<ClientTreeNode key={child.id} node={child} onEdit={onEdit} onAddChild={onAddChild} depth={depth + 1} />
))}
</div>
);
}
export function ClientsAdminClient() {
const [editing, setEditing] = useState<EditingClient | null>(null);
const [search, setSearch] = useState("");
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: tree, isLoading } = trpc.clientEntity.getTree.useQuery();
const { data: flatList } = trpc.clientEntity.list.useQuery();
const createMut = trpc.clientEntity.create.useMutation({
onSuccess: () => { void utils.clientEntity.getTree.invalidate(); void utils.clientEntity.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const updateMut = trpc.clientEntity.update.useMutation({
onSuccess: () => { void utils.clientEntity.getTree.invalidate(); void utils.clientEntity.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const allClients = (flatList ?? []) as unknown as ClientRow[];
function openCreate(parentId?: string) {
setEditing({ name: "", code: "", parentId: parentId ?? "", sortOrder: 0 });
setError(null);
}
function openEdit(c: ClientRow) {
setEditing({
id: c.id,
name: c.name,
code: c.code ?? "",
parentId: c.parentId ?? "",
sortOrder: c.sortOrder,
});
setError(null);
}
function handleSave() {
if (!editing) return;
setError(null);
if (editing.id) {
updateMut.mutate({
id: editing.id,
data: {
name: editing.name,
code: editing.code || undefined,
parentId: editing.parentId || undefined,
sortOrder: editing.sortOrder,
},
});
} else {
createMut.mutate({
name: editing.name,
code: editing.code || undefined,
parentId: editing.parentId || undefined,
sortOrder: editing.sortOrder,
});
}
}
const isPending = createMut.isPending || updateMut.isPending;
const treeNodes = (tree ?? []) as unknown as ClientNode[];
// Simple client-side filter on tree
function filterTree(nodes: ClientNode[], q: string): ClientNode[] {
if (!q) return nodes;
const lower = q.toLowerCase();
return nodes.reduce<ClientNode[]>((acc, node) => {
const filteredChildren = filterTree(node.children, q);
if (node.name.toLowerCase().includes(lower) || (node.code ?? "").toLowerCase().includes(lower) || filteredChildren.length > 0) {
acc.push({ ...node, children: filteredChildren });
}
return acc;
}, []);
}
const filteredTree = filterTree(treeNodes, search);
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Clients</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Client hierarchy for project assignment and chargeability reporting
</p>
</div>
<button
type="button"
onClick={() => openCreate()}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
+ Add Client
</button>
</div>
<div className="mb-4">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search clients..."
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64 bg-white dark:bg-gray-900 dark:text-gray-100"
/>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{error}
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
</div>
)}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-2">
{isLoading && <div className="text-center py-8 text-gray-400">Loading...</div>}
{!isLoading && filteredTree.length === 0 && (
<div className="text-center py-8 text-gray-400">
{search ? "No clients match your search." : "No clients yet."}
</div>
)}
{filteredTree.map((node) => (
<ClientTreeNode key={node.id} node={node} onEdit={openEdit} onAddChild={(pid) => openCreate(pid)} />
))}
</div>
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Client" : "Add Client"}
</h2>
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
type="text"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
placeholder="e.g. BMW Group"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code</label>
<input
type="text"
value={editing.code}
onChange={(e) => setEditing({ ...editing, code: e.target.value })}
placeholder="BMW"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
<input
type="number"
value={editing.sortOrder}
onChange={(e) => setEditing({ ...editing, sortOrder: parseInt(e.target.value) || 0 })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Client</label>
<select
value={editing.parentId}
onChange={(e) => setEditing({ ...editing, parentId: e.target.value })}
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"
>
<option value=""> Top level (no parent) </option>
{allClients
.filter((c) => c.id !== editing.id && c.isActive)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name} {c.code ? `[${c.code}]` : ""}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditing(null)} 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={handleSave}
disabled={isPending || !editing.name}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,413 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type CountryRow = {
id: string;
code: string;
name: string;
dailyWorkingHours: number;
scheduleRules: unknown;
isActive: boolean;
metroCities: { id: string; name: string }[];
};
type EditingCountry = {
id?: string;
code: string;
name: string;
dailyWorkingHours: number;
hasSpainRules: boolean;
fridayHours: number;
summerFrom: string;
summerTo: string;
summerHours: number;
regularHours: number;
};
const emptyCountry: EditingCountry = {
code: "",
name: "",
dailyWorkingHours: 8,
hasSpainRules: false,
fridayHours: 6.5,
summerFrom: "07-01",
summerTo: "09-15",
summerHours: 6.5,
regularHours: 9,
};
function parseSpainRules(rules: unknown): Partial<EditingCountry> {
if (!rules || typeof rules !== "object") return { hasSpainRules: false };
const r = rules as Record<string, unknown>;
if (r.type !== "spain") return { hasSpainRules: false };
const sp = r as { fridayHours?: number; summerPeriod?: { from?: string; to?: string }; summerHours?: number; regularHours?: number };
return {
hasSpainRules: true,
fridayHours: sp.fridayHours ?? 6.5,
summerFrom: sp.summerPeriod?.from ?? "07-01",
summerTo: sp.summerPeriod?.to ?? "09-15",
summerHours: sp.summerHours ?? 6.5,
regularHours: sp.regularHours ?? 9,
};
}
export function CountriesClient() {
const [editing, setEditing] = useState<EditingCountry | null>(null);
const [cityName, setCityName] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: countries, isLoading } = trpc.country.list.useQuery();
// @ts-ignore TS2589: tRPC infers union type too deeply for nullable JSONB scheduleRules schema
const createMut = trpc.country.create.useMutation({
onSuccess: () => { void utils.country.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const updateMut = trpc.country.update.useMutation({
onSuccess: () => { void utils.country.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const createCityMut = trpc.country.createCity.useMutation({
onSuccess: () => { void utils.country.list.invalidate(); setCityName(""); },
onError: (e) => setError(e.message),
});
const deleteCityMut = trpc.country.deleteCity.useMutation({
onSuccess: () => void utils.country.list.invalidate(),
onError: (e) => setError(e.message),
});
function openCreate() {
setEditing({ ...emptyCountry });
setError(null);
}
function openEdit(c: CountryRow) {
const spainParts = parseSpainRules(c.scheduleRules);
setEditing({
id: c.id,
code: c.code,
name: c.name,
dailyWorkingHours: c.dailyWorkingHours,
hasSpainRules: false,
fridayHours: 6.5,
summerFrom: "07-01",
summerTo: "09-15",
summerHours: 6.5,
regularHours: 9,
...spainParts,
});
setError(null);
}
function handleSave() {
if (!editing) return;
setError(null);
const scheduleRules = editing.hasSpainRules
? {
type: "spain" as const,
fridayHours: editing.fridayHours,
summerPeriod: { from: editing.summerFrom, to: editing.summerTo },
summerHours: editing.summerHours,
regularHours: editing.regularHours,
}
: null;
if (editing.id) {
updateMut.mutate({
id: editing.id,
data: {
code: editing.code,
name: editing.name,
dailyWorkingHours: editing.dailyWorkingHours,
scheduleRules,
},
});
} else {
createMut.mutate({
code: editing.code,
name: editing.name,
dailyWorkingHours: editing.dailyWorkingHours,
scheduleRules,
});
}
}
function handleAddCity(countryId: string) {
if (!cityName.trim()) return;
createCityMut.mutate({ countryId, name: cityName.trim() });
}
const isPending = createMut.isPending || updateMut.isPending;
const rows = (countries ?? []) as unknown as CountryRow[];
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Countries</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage countries, daily working hours, and metro cities
</p>
</div>
<button
type="button"
onClick={openCreate}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
+ Add Country
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{error}
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
</div>
)}
{/* Country List */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Code</th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Name</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Daily Hours</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Schedule</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Cities</th>
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr><td colSpan={6} className="text-center py-8 text-gray-400">Loading...</td></tr>
)}
{!isLoading && rows.length === 0 && (
<tr><td colSpan={6} className="text-center py-8 text-gray-400">No countries yet.</td></tr>
)}
{rows.map((c) => (
<tr key={c.id} className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-4 py-3 font-mono font-medium text-gray-900 dark:text-gray-100">{c.code}</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{c.name}</td>
<td className="px-4 py-3 text-center text-gray-600 dark:text-gray-400">{c.dailyWorkingHours}h</td>
<td className="px-4 py-3 text-center">
{c.scheduleRules && typeof c.scheduleRules === "object" && (c.scheduleRules as Record<string, unknown>).type === "spain" ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">Spain</span>
) : (
<span className="text-gray-400 text-xs">Standard</span>
)}
</td>
<td className="px-4 py-3 text-center">
<button
type="button"
onClick={() => setExpandedId(expandedId === c.id ? null : c.id)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
{c.metroCities.length} cities {expandedId === c.id ? "▲" : "▼"}
</button>
</td>
<td className="px-4 py-3 text-right">
<button type="button" onClick={() => openEdit(c)} className="text-xs text-brand-600 hover:text-brand-800 font-medium">Edit</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Expanded Metro Cities */}
{expandedId && (() => {
const country = rows.find((c) => c.id === expandedId);
if (!country) return null;
return (
<div className="mt-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Metro Cities for {country.name}
</h3>
<div className="flex flex-wrap gap-2 mb-3">
{country.metroCities.map((city) => (
<span key={city.id} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300">
{city.name}
<button
type="button"
onClick={() => {
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"
>
&times;
</button>
</span>
))}
{country.metroCities.length === 0 && (
<span className="text-sm text-gray-400">No metro cities yet</span>
)}
</div>
<div className="flex gap-2">
<input
type="text"
value={cityName}
onChange={(e) => setCityName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleAddCity(country.id); }}
placeholder="New city name..."
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 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 flex-1"
/>
<button
type="button"
onClick={() => handleAddCity(country.id)}
disabled={createCityMut.isPending || !cityName.trim()}
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
Add
</button>
</div>
</div>
);
})()}
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-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">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Country" : "Add Country"}
</h2>
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code</label>
<input
type="text"
value={editing.code}
onChange={(e) => setEditing({ ...editing, code: e.target.value.toUpperCase() })}
maxLength={3}
placeholder="DE"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Daily Hours</label>
<input
type="number"
value={editing.dailyWorkingHours}
onChange={(e) => setEditing({ ...editing, dailyWorkingHours: parseFloat(e.target.value) || 8 })}
min={1}
max={24}
step={0.5}
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"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
type="text"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
placeholder="Germany"
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"
/>
</div>
{/* Spain Schedule Rules */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={editing.hasSpainRules}
onChange={(e) => setEditing({ ...editing, hasSpainRules: e.target.checked })}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Variable schedule (Spain-type)
</label>
{editing.hasSpainRules && (
<div className="mt-3 space-y-3 pl-6 border-l-2 border-amber-300 dark:border-amber-700">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Friday Hours</label>
<input
type="number"
value={editing.fridayHours}
onChange={(e) => setEditing({ ...editing, fridayHours: parseFloat(e.target.value) || 6.5 })}
step={0.5}
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Regular Hours (Mon-Thu)</label>
<input
type="number"
value={editing.regularHours}
onChange={(e) => setEditing({ ...editing, regularHours: parseFloat(e.target.value) || 9 })}
step={0.5}
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"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer From</label>
<input
type="text"
value={editing.summerFrom}
onChange={(e) => setEditing({ ...editing, summerFrom: e.target.value })}
placeholder="07-01"
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer To</label>
<input
type="text"
value={editing.summerTo}
onChange={(e) => setEditing({ ...editing, summerTo: e.target.value })}
placeholder="09-15"
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer Hours</label>
<input
type="number"
value={editing.summerHours}
onChange={(e) => setEditing({ ...editing, summerHours: parseFloat(e.target.value) || 6.5 })}
step={0.5}
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"
/>
</div>
</div>
</div>
)}
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditing(null)} 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={handleSave}
disabled={isPending || !editing.code || !editing.name}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,420 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type EffortUnitMode = "per_frame" | "per_item" | "flat";
type EditingRule = {
id?: string;
scopeType: string;
discipline: string;
chapter: string;
unitMode: EffortUnitMode;
hoursPerUnit: number;
description: string;
sortOrder: number;
};
type EditingRuleSet = {
id?: string;
name: string;
description: string;
isDefault: boolean;
rules: EditingRule[];
};
const UNIT_MODE_LABELS: Record<EffortUnitMode, string> = {
per_frame: "Per frame",
per_item: "Per item",
flat: "Flat",
};
const SCOPE_TYPE_PRESETS = ["SHOT", "ASSET", "ENVIRONMENT", "SEQUENCE", "OTHER"];
const DISCIPLINE_PRESETS = [
"3D Animation",
"3D Lighting",
"3D Modeling",
"3D Rigging",
"3D Environment",
"Compositing",
"Motion Graphics",
"Art Direction",
"Conception / R&D",
"Project Management",
"Production Supervisor",
"DataPrep",
"Audio Production",
];
const emptyRule: EditingRule = {
scopeType: "SHOT",
discipline: "",
chapter: "",
unitMode: "per_frame",
hoursPerUnit: 0,
description: "",
sortOrder: 0,
};
const emptyRuleSet: EditingRuleSet = {
name: "",
description: "",
isDefault: false,
rules: [],
};
export function EffortRulesClient() {
const utils = trpc.useUtils();
const { data: ruleSets, isLoading } = trpc.effortRule.list.useQuery();
const createMutation = trpc.effortRule.create.useMutation({
onSuccess: () => {
utils.effortRule.list.invalidate();
setEditing(null);
},
});
const updateMutation = trpc.effortRule.update.useMutation({
onSuccess: () => {
utils.effortRule.list.invalidate();
setEditing(null);
},
});
const deleteMutation = trpc.effortRule.delete.useMutation({
onSuccess: () => utils.effortRule.list.invalidate(),
});
const [editing, setEditing] = useState<EditingRuleSet | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
function handleSave() {
if (!editing) return;
const payload = {
name: editing.name,
description: editing.description || undefined,
isDefault: editing.isDefault,
rules: editing.rules.map((r, i) => ({
scopeType: r.scopeType,
discipline: r.discipline,
...(r.chapter ? { chapter: r.chapter } : {}),
unitMode: r.unitMode,
hoursPerUnit: r.hoursPerUnit,
...(r.description ? { description: r.description } : {}),
sortOrder: i,
})),
};
if (editing.id) {
updateMutation.mutate({ id: editing.id, ...payload });
} else {
createMutation.mutate(payload);
}
}
function handleEdit(ruleSet: NonNullable<typeof ruleSets>[number]) {
setEditing({
id: ruleSet.id,
name: ruleSet.name,
description: ruleSet.description ?? "",
isDefault: ruleSet.isDefault,
rules: ruleSet.rules.map((r) => ({
id: r.id,
scopeType: r.scopeType,
discipline: r.discipline,
chapter: r.chapter ?? "",
unitMode: r.unitMode as EffortUnitMode,
hoursPerUnit: r.hoursPerUnit,
description: r.description ?? "",
sortOrder: r.sortOrder,
})),
});
}
function addRule() {
if (!editing) return;
setEditing({
...editing,
rules: [...editing.rules, { ...emptyRule, sortOrder: editing.rules.length }],
});
}
function removeRule(index: number) {
if (!editing) return;
setEditing({
...editing,
rules: editing.rules.filter((_, i) => i !== index),
});
}
function updateRule(index: number, updates: Partial<EditingRule>) {
if (!editing) return;
setEditing({
...editing,
rules: editing.rules.map((r, i) => (i === index ? { ...r, ...updates } : r)),
});
}
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Effort Rules</h1>
<p className="text-sm text-gray-500">
Define rules for auto-generating demand lines from scope items.
</p>
</div>
{!editing && (
<button
onClick={() => setEditing({ ...emptyRuleSet })}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700"
>
New rule set
</button>
)}
</div>
{/* Editor */}
{editing && (
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-gray-900">
{editing.id ? "Edit rule set" : "New rule set"}
</h2>
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Name</label>
<input
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm"
placeholder="e.g. CGI Standard Rules"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Description</label>
<input
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm"
placeholder="Optional description"
/>
</div>
</div>
<label className="mb-4 flex items-center gap-2 text-sm text-gray-600">
<input
type="checkbox"
checked={editing.isDefault}
onChange={(e) => setEditing({ ...editing, isDefault: e.target.checked })}
className="rounded border-gray-300"
/>
Default rule set (auto-selected for new estimates)
</label>
{/* Rules table */}
<div className="mb-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">Rules ({editing.rules.length})</h3>
<button
onClick={addRule}
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
>
+ Add rule
</button>
</div>
{editing.rules.length === 0 ? (
<p className="rounded-xl bg-gray-50 p-4 text-center text-sm text-gray-400">
No rules yet. Add rules to define how scope items expand into demand lines.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-2 font-medium">Scope type</th>
<th className="px-2 py-2 font-medium">Discipline</th>
<th className="px-2 py-2 font-medium">Chapter</th>
<th className="px-2 py-2 font-medium">Unit mode</th>
<th className="px-2 py-2 text-right font-medium">Hours/unit</th>
<th className="pl-2 py-2 font-medium w-10"></th>
</tr>
</thead>
<tbody>
{editing.rules.map((rule, i) => (
<tr key={i} className="border-b border-gray-100">
<td className="py-2 pr-2">
<select
value={rule.scopeType}
onChange={(e) => updateRule(i, { scopeType: e.target.value })}
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
>
{SCOPE_TYPE_PRESETS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</td>
<td className="px-2 py-2">
<input
value={rule.discipline}
onChange={(e) => updateRule(i, { discipline: e.target.value })}
list="discipline-presets"
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
placeholder="Discipline"
/>
</td>
<td className="px-2 py-2">
<input
value={rule.chapter}
onChange={(e) => updateRule(i, { chapter: e.target.value })}
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
placeholder="Chapter"
/>
</td>
<td className="px-2 py-2">
<select
value={rule.unitMode}
onChange={(e) => updateRule(i, { unitMode: e.target.value as EffortUnitMode })}
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
>
{(Object.entries(UNIT_MODE_LABELS) as [EffortUnitMode, string][]).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</td>
<td className="px-2 py-2">
<input
type="number"
step="0.01"
min="0"
value={rule.hoursPerUnit}
onChange={(e) => updateRule(i, { hoursPerUnit: parseFloat(e.target.value) || 0 })}
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
/>
</td>
<td className="pl-2 py-2">
<button
onClick={() => removeRule(i)}
className="text-red-400 hover:text-red-600"
title="Remove"
>
x
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<datalist id="discipline-presets">
{DISCIPLINE_PRESETS.map((d) => (
<option key={d} value={d} />
))}
</datalist>
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={isSaving || !editing.name.trim()}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{isSaving ? "Saving..." : "Save"}
</button>
<button
onClick={() => setEditing(null)}
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
>
Cancel
</button>
</div>
{(createMutation.error || updateMutation.error) && (
<p className="mt-2 text-sm text-red-600">
{createMutation.error?.message || updateMutation.error?.message}
</p>
)}
</div>
)}
{/* List */}
{isLoading && <p className="text-center text-sm text-gray-400">Loading...</p>}
{ruleSets && ruleSets.length === 0 && !editing && (
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-8 text-center text-sm text-gray-500">
No effort rule sets yet. Create one to define how scope items expand into demand lines.
</div>
)}
{ruleSets?.map((rs) => (
<div key={rs.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-base font-semibold text-gray-900">{rs.name}</h3>
{rs.isDefault && (
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700">Default</span>
)}
<span className="text-sm text-gray-500">{rs.rules.length} rules</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setExpandedId(expandedId === rs.id ? null : rs.id)}
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
>
{expandedId === rs.id ? "Collapse" : "Expand"}
</button>
<button
onClick={() => handleEdit(rs)}
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
>
Edit
</button>
<button
onClick={() => {
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"
>
Delete
</button>
</div>
</div>
{rs.description && <p className="mt-1 text-sm text-gray-500">{rs.description}</p>}
{expandedId === rs.id && rs.rules.length > 0 && (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Scope type</th>
<th className="px-3 py-2 font-medium">Discipline</th>
<th className="px-3 py-2 font-medium">Chapter</th>
<th className="px-3 py-2 font-medium">Unit mode</th>
<th className="pl-3 py-2 text-right font-medium">Hours/unit</th>
</tr>
</thead>
<tbody>
{rs.rules.map((r) => (
<tr key={r.id} className="border-b border-gray-100">
<td className="py-1.5 pr-3 text-gray-900">{r.scopeType}</td>
<td className="px-3 py-1.5 text-gray-700">{r.discipline}</td>
<td className="px-3 py-1.5 text-gray-500">{r.chapter || "\u2014"}</td>
<td className="px-3 py-1.5 text-gray-500">{UNIT_MODE_LABELS[r.unitMode as EffortUnitMode] ?? r.unitMode}</td>
<td className="pl-3 py-1.5 text-right tabular-nums text-gray-700">{r.hoursPerUnit}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,475 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type EditingRule = {
id?: string;
chapter: string;
location: string;
level: string;
costMultiplier: number;
billMultiplier: number;
shoringRatio: string;
additionalEffortRatio: string;
description: string;
sortOrder: number;
};
type EditingSet = {
id?: string;
name: string;
description: string;
isDefault: boolean;
rules: EditingRule[];
};
const CHAPTER_PRESETS = [
"Animation",
"Compositing",
"3D Modeling",
"3D Lighting",
"3D Rigging",
"3D Environment",
"Motion Graphics",
"Art Direction",
"Project Management",
];
const LOCATION_PRESETS = [
"Germany",
"India",
"Poland",
"Romania",
"Spain",
"UK",
"USA",
"Canada",
];
const LEVEL_PRESETS = [
"Junior",
"Mid",
"Senior",
"Lead",
"Principal",
];
const emptyRule: EditingRule = {
chapter: "",
location: "",
level: "",
costMultiplier: 1.0,
billMultiplier: 1.0,
shoringRatio: "",
additionalEffortRatio: "",
description: "",
sortOrder: 0,
};
const emptySet: EditingSet = {
name: "",
description: "",
isDefault: false,
rules: [],
};
export function ExperienceMultipliersClient() {
const utils = trpc.useUtils();
const { data: sets, isLoading } = trpc.experienceMultiplier.list.useQuery();
const createMutation = trpc.experienceMultiplier.create.useMutation({
onSuccess: () => {
utils.experienceMultiplier.list.invalidate();
setEditing(null);
},
});
const updateMutation = trpc.experienceMultiplier.update.useMutation({
onSuccess: () => {
utils.experienceMultiplier.list.invalidate();
setEditing(null);
},
});
const deleteMutation = trpc.experienceMultiplier.delete.useMutation({
onSuccess: () => utils.experienceMultiplier.list.invalidate(),
});
const [editing, setEditing] = useState<EditingSet | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
function handleSave() {
if (!editing) return;
const payload = {
name: editing.name,
description: editing.description || undefined,
isDefault: editing.isDefault,
rules: editing.rules.map((r, i) => ({
...(r.chapter ? { chapter: r.chapter } : {}),
...(r.location ? { location: r.location } : {}),
...(r.level ? { level: r.level } : {}),
costMultiplier: r.costMultiplier,
billMultiplier: r.billMultiplier,
...(r.shoringRatio !== "" ? { shoringRatio: parseFloat(r.shoringRatio) } : {}),
...(r.additionalEffortRatio !== "" ? { additionalEffortRatio: parseFloat(r.additionalEffortRatio) } : {}),
...(r.description ? { description: r.description } : {}),
sortOrder: i,
})),
};
if (editing.id) {
updateMutation.mutate({ id: editing.id, ...payload });
} else {
createMutation.mutate(payload);
}
}
function handleEdit(set: NonNullable<typeof sets>[number]) {
setEditing({
id: set.id,
name: set.name,
description: set.description ?? "",
isDefault: set.isDefault,
rules: set.rules.map((r) => ({
id: r.id,
chapter: r.chapter ?? "",
location: r.location ?? "",
level: r.level ?? "",
costMultiplier: r.costMultiplier,
billMultiplier: r.billMultiplier,
shoringRatio: r.shoringRatio != null ? String(r.shoringRatio) : "",
additionalEffortRatio: r.additionalEffortRatio != null ? String(r.additionalEffortRatio) : "",
description: r.description ?? "",
sortOrder: r.sortOrder,
})),
});
}
function addRule() {
if (!editing) return;
setEditing({
...editing,
rules: [...editing.rules, { ...emptyRule, sortOrder: editing.rules.length }],
});
}
function removeRule(index: number) {
if (!editing) return;
setEditing({
...editing,
rules: editing.rules.filter((_, i) => i !== index),
});
}
function updateRule(index: number, updates: Partial<EditingRule>) {
if (!editing) return;
setEditing({
...editing,
rules: editing.rules.map((r, i) => (i === index ? { ...r, ...updates } : r)),
});
}
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<div className="mx-auto max-w-6xl space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Experience Multipliers</h1>
<p className="text-sm text-gray-500">
Define rate and effort adjustments by chapter, location, and experience level.
</p>
</div>
{!editing && (
<button
onClick={() => setEditing({ ...emptySet })}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700"
>
New multiplier set
</button>
)}
</div>
{/* Editor */}
{editing && (
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-gray-900">
{editing.id ? "Edit multiplier set" : "New multiplier set"}
</h2>
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Name</label>
<input
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm"
placeholder="e.g. CGI Standard Multipliers"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Description</label>
<input
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm"
placeholder="Optional description"
/>
</div>
</div>
<label className="mb-4 flex items-center gap-2 text-sm text-gray-600">
<input
type="checkbox"
checked={editing.isDefault}
onChange={(e) => setEditing({ ...editing, isDefault: e.target.checked })}
className="rounded border-gray-300"
/>
Default set (auto-selected when applying multipliers)
</label>
{/* Rules table */}
<div className="mb-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">Rules ({editing.rules.length})</h3>
<button
onClick={addRule}
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
>
+ Add rule
</button>
</div>
{editing.rules.length === 0 ? (
<p className="rounded-xl bg-gray-50 p-4 text-center text-sm text-gray-400">
No rules yet. Add rules to define rate and effort adjustments.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-2 font-medium">Chapter</th>
<th className="px-2 py-2 font-medium">Location</th>
<th className="px-2 py-2 font-medium">Level</th>
<th className="px-2 py-2 text-right font-medium">Cost mult.</th>
<th className="px-2 py-2 text-right font-medium">Bill mult.</th>
<th className="px-2 py-2 text-right font-medium">Shoring %</th>
<th className="px-2 py-2 text-right font-medium">Add. effort %</th>
<th className="pl-2 py-2 font-medium w-10"></th>
</tr>
</thead>
<tbody>
{editing.rules.map((rule, i) => (
<tr key={i} className="border-b border-gray-100">
<td className="py-2 pr-2">
<input
value={rule.chapter}
onChange={(e) => updateRule(i, { chapter: e.target.value })}
list="chapter-presets"
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
placeholder="Any"
/>
</td>
<td className="px-2 py-2">
<input
value={rule.location}
onChange={(e) => updateRule(i, { location: e.target.value })}
list="location-presets"
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
placeholder="Any"
/>
</td>
<td className="px-2 py-2">
<input
value={rule.level}
onChange={(e) => updateRule(i, { level: e.target.value })}
list="level-presets"
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
placeholder="Any"
/>
</td>
<td className="px-2 py-2">
<input
type="number"
step="0.01"
min="0"
value={rule.costMultiplier}
onChange={(e) => updateRule(i, { costMultiplier: parseFloat(e.target.value) || 0 })}
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
/>
</td>
<td className="px-2 py-2">
<input
type="number"
step="0.01"
min="0"
value={rule.billMultiplier}
onChange={(e) => updateRule(i, { billMultiplier: parseFloat(e.target.value) || 0 })}
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
/>
</td>
<td className="px-2 py-2">
<input
type="number"
step="0.01"
min="0"
max="1"
value={rule.shoringRatio}
onChange={(e) => updateRule(i, { shoringRatio: e.target.value })}
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
placeholder="-"
/>
</td>
<td className="px-2 py-2">
<input
type="number"
step="0.01"
min="0"
value={rule.additionalEffortRatio}
onChange={(e) => updateRule(i, { additionalEffortRatio: e.target.value })}
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
placeholder="-"
/>
</td>
<td className="pl-2 py-2">
<button
onClick={() => removeRule(i)}
className="text-red-400 hover:text-red-600"
title="Remove"
>
x
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<datalist id="chapter-presets">
{CHAPTER_PRESETS.map((d) => (
<option key={d} value={d} />
))}
</datalist>
<datalist id="location-presets">
{LOCATION_PRESETS.map((d) => (
<option key={d} value={d} />
))}
</datalist>
<datalist id="level-presets">
{LEVEL_PRESETS.map((d) => (
<option key={d} value={d} />
))}
</datalist>
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={isSaving || !editing.name.trim()}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{isSaving ? "Saving..." : "Save"}
</button>
<button
onClick={() => setEditing(null)}
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
>
Cancel
</button>
</div>
{(createMutation.error || updateMutation.error) && (
<p className="mt-2 text-sm text-red-600">
{createMutation.error?.message || updateMutation.error?.message}
</p>
)}
</div>
)}
{/* List */}
{isLoading && <p className="text-center text-sm text-gray-400">Loading...</p>}
{sets && sets.length === 0 && !editing && (
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-8 text-center text-sm text-gray-500">
No experience multiplier sets yet. Create one to define rate and effort adjustments.
</div>
)}
{sets?.map((s) => (
<div key={s.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-base font-semibold text-gray-900">{s.name}</h3>
{s.isDefault && (
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700">Default</span>
)}
<span className="text-sm text-gray-500">{s.rules.length} rules</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setExpandedId(expandedId === s.id ? null : s.id)}
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
>
{expandedId === s.id ? "Collapse" : "Expand"}
</button>
<button
onClick={() => handleEdit(s)}
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
>
Edit
</button>
<button
onClick={() => {
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"
>
Delete
</button>
</div>
</div>
{s.description && <p className="mt-1 text-sm text-gray-500">{s.description}</p>}
{expandedId === s.id && s.rules.length > 0 && (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Chapter</th>
<th className="px-3 py-2 font-medium">Location</th>
<th className="px-3 py-2 font-medium">Level</th>
<th className="px-3 py-2 text-right font-medium">Cost mult.</th>
<th className="px-3 py-2 text-right font-medium">Bill mult.</th>
<th className="px-3 py-2 text-right font-medium">Shoring</th>
<th className="pl-3 py-2 text-right font-medium">Add. effort</th>
</tr>
</thead>
<tbody>
{s.rules.map((r) => (
<tr key={r.id} className="border-b border-gray-100">
<td className="py-1.5 pr-3 text-gray-900">{r.chapter || "\u2014"}</td>
<td className="px-3 py-1.5 text-gray-700">{r.location || "\u2014"}</td>
<td className="px-3 py-1.5 text-gray-500">{r.level || "\u2014"}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">{r.costMultiplier.toFixed(2)}x</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">{r.billMultiplier.toFixed(2)}x</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-500">
{r.shoringRatio != null ? `${(r.shoringRatio * 100).toFixed(0)}%` : "\u2014"}
</td>
<td className="pl-3 py-1.5 text-right tabular-nums text-gray-500">
{r.additionalEffortRatio != null ? `${(r.additionalEffortRatio * 100).toFixed(0)}%` : "\u2014"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,322 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type LevelRow = { id: string; name: string; groupId: string };
type GroupRow = {
id: string;
name: string;
targetPercentage: number;
sortOrder: number;
levels: LevelRow[];
};
type EditingGroup = {
id?: string;
name: string;
targetPercentage: number;
sortOrder: number;
};
type EditingLevel = {
id?: string;
name: string;
groupId: string;
};
export function ManagementLevelsClient() {
const [editingGroup, setEditingGroup] = useState<EditingGroup | null>(null);
const [editingLevel, setEditingLevel] = useState<EditingLevel | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: groups, isLoading } = trpc.managementLevel.listGroups.useQuery();
const createGroupMut = trpc.managementLevel.createGroup.useMutation({
onSuccess: () => { void utils.managementLevel.listGroups.invalidate(); setEditingGroup(null); },
onError: (e) => setError(e.message),
});
const updateGroupMut = trpc.managementLevel.updateGroup.useMutation({
onSuccess: () => { void utils.managementLevel.listGroups.invalidate(); setEditingGroup(null); },
onError: (e) => setError(e.message),
});
const createLevelMut = trpc.managementLevel.createLevel.useMutation({
onSuccess: () => { void utils.managementLevel.listGroups.invalidate(); setEditingLevel(null); },
onError: (e) => setError(e.message),
});
const updateLevelMut = trpc.managementLevel.updateLevel.useMutation({
onSuccess: () => { void utils.managementLevel.listGroups.invalidate(); setEditingLevel(null); },
onError: (e) => setError(e.message),
});
const deleteLevelMut = trpc.managementLevel.deleteLevel.useMutation({
onSuccess: () => void utils.managementLevel.listGroups.invalidate(),
onError: (e) => setError(e.message),
});
function openCreateGroup() {
const maxOrder = Math.max(0, ...(groups ?? []).map((g) => (g as unknown as GroupRow).sortOrder));
setEditingGroup({ name: "", targetPercentage: 0, sortOrder: maxOrder + 1 });
setError(null);
}
function openEditGroup(g: GroupRow) {
setEditingGroup({ id: g.id, name: g.name, targetPercentage: g.targetPercentage, sortOrder: g.sortOrder });
setError(null);
}
function handleSaveGroup() {
if (!editingGroup) return;
setError(null);
if (editingGroup.id) {
updateGroupMut.mutate({
id: editingGroup.id,
data: { name: editingGroup.name, targetPercentage: editingGroup.targetPercentage, sortOrder: editingGroup.sortOrder },
});
} else {
createGroupMut.mutate({
name: editingGroup.name,
targetPercentage: editingGroup.targetPercentage,
sortOrder: editingGroup.sortOrder,
});
}
}
function openCreateLevel(groupId: string) {
setEditingLevel({ name: "", groupId });
setError(null);
}
function openEditLevel(l: LevelRow) {
setEditingLevel({ id: l.id, name: l.name, groupId: l.groupId });
setError(null);
}
function handleSaveLevel() {
if (!editingLevel) return;
setError(null);
if (editingLevel.id) {
updateLevelMut.mutate({
id: editingLevel.id,
data: { name: editingLevel.name, groupId: editingLevel.groupId },
});
} else {
createLevelMut.mutate({ name: editingLevel.name, groupId: editingLevel.groupId });
}
}
const isGroupPending = createGroupMut.isPending || updateGroupMut.isPending;
const isLevelPending = createLevelMut.isPending || updateLevelMut.isPending;
const rows = (groups ?? []) as unknown as GroupRow[];
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Management Levels</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Level groups with chargeability targets and individual levels
</p>
</div>
<button
type="button"
onClick={openCreateGroup}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
+ Add Group
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{error}
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
</div>
)}
{isLoading && <div className="text-center py-8 text-gray-400">Loading...</div>}
<div className="space-y-4">
{rows.map((group) => (
<div key={group.id} className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Group header */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{group.name}</h3>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400">
Target: {Math.round(group.targetPercentage * 100)}%
</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => openCreateLevel(group.id)}
className="text-xs text-green-600 hover:text-green-800 font-medium"
>
+ Level
</button>
<button
type="button"
onClick={() => openEditGroup(group)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit Group
</button>
</div>
</div>
{/* Levels */}
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{group.levels.length === 0 && (
<div className="px-4 py-3 text-sm text-gray-400">No levels in this group yet.</div>
)}
{group.levels.map((level) => (
<div key={level.id} className="flex items-center justify-between px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/30 group">
<span className="text-sm text-gray-900 dark:text-gray-100">{level.name}</span>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => openEditLevel(level)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
<button
type="button"
onClick={() => {
if (confirm(`Delete level "${level.name}"?`)) {
deleteLevelMut.mutate({ id: level.id });
}
}}
className="text-xs text-red-500 hover:text-red-700 font-medium"
>
Delete
</button>
</div>
</div>
))}
</div>
</div>
))}
{!isLoading && rows.length === 0 && (
<div className="text-center py-8 text-gray-400">No management level groups yet.</div>
)}
</div>
{/* Group Modal */}
{editingGroup && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingGroup.id ? "Edit Group" : "Add Group"}
</h2>
<button type="button" onClick={() => setEditingGroup(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
type="text"
value={editingGroup.name}
onChange={(e) => setEditingGroup({ ...editingGroup, name: e.target.value })}
placeholder="e.g. Senior Management"
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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Target % (0-100)</label>
<input
type="number"
value={Math.round(editingGroup.targetPercentage * 100)}
onChange={(e) => setEditingGroup({ ...editingGroup, targetPercentage: (parseInt(e.target.value) || 0) / 100 })}
min={0}
max={100}
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
<input
type="number"
value={editingGroup.sortOrder}
onChange={(e) => setEditingGroup({ ...editingGroup, sortOrder: parseInt(e.target.value) || 0 })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditingGroup(null)} 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={handleSaveGroup}
disabled={isGroupPending || !editingGroup.name}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isGroupPending ? "Saving..." : editingGroup.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
{/* Level Modal */}
{editingLevel && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-sm mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingLevel.id ? "Edit Level" : "Add Level"}
</h2>
<button type="button" onClick={() => setEditingLevel(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Level Name</label>
<input
type="text"
value={editingLevel.name}
onChange={(e) => setEditingLevel({ ...editingLevel, name: e.target.value })}
placeholder="e.g. Managing Director"
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Group</label>
<select
value={editingLevel.groupId}
onChange={(e) => setEditingLevel({ ...editingLevel, groupId: e.target.value })}
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"
>
{rows.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditingLevel(null)} 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={handleSaveLevel}
disabled={isLevelPending || !editingLevel.name}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isLevelPending ? "Saving..." : editingLevel.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,282 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type OrgUnitRow = {
id: string;
name: string;
shortName: string | null;
level: number;
parentId: string | null;
sortOrder: number;
isActive: boolean;
};
type OrgUnitNode = OrgUnitRow & { children: OrgUnitNode[] };
type EditingUnit = {
id?: string;
name: string;
shortName: string;
level: number;
parentId: string;
sortOrder: number;
};
const LEVEL_LABELS: Record<number, string> = { 5: "L5 — Division", 6: "L6 — Department", 7: "L7 — Team" };
const LEVEL_COLORS: Record<number, string> = {
5: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400",
6: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
7: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400",
};
function TreeNode({
node,
onEdit,
depth = 0,
}: {
node: OrgUnitNode;
onEdit: (u: OrgUnitRow) => void;
depth?: number;
}) {
const [expanded, setExpanded] = useState(true);
const hasChildren = node.children.length > 0;
return (
<div>
<div
className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-800/30 rounded-lg group"
style={{ paddingLeft: `${depth * 24 + 12}px` }}
>
{hasChildren ? (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 text-xs"
>
{expanded ? "▼" : "▶"}
</button>
) : (
<span className="w-5" />
)}
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${LEVEL_COLORS[node.level] ?? "bg-gray-100 text-gray-600"}`}>
L{node.level}
</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 flex-1">
{node.name}
{node.shortName && <span className="text-gray-400 ml-1">({node.shortName})</span>}
</span>
{!node.isActive && (
<span className="text-xs text-gray-400 italic">inactive</span>
)}
<button
type="button"
onClick={() => onEdit(node)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium opacity-0 group-hover:opacity-100 transition-opacity"
>
Edit
</button>
</div>
{expanded && node.children.map((child) => (
<TreeNode key={child.id} node={child} onEdit={onEdit} depth={depth + 1} />
))}
</div>
);
}
export function OrgUnitsClient() {
const [editing, setEditing] = useState<EditingUnit | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: tree, isLoading } = trpc.orgUnit.getTree.useQuery();
const { data: flatList } = trpc.orgUnit.list.useQuery();
const createMut = trpc.orgUnit.create.useMutation({
onSuccess: () => { void utils.orgUnit.getTree.invalidate(); void utils.orgUnit.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const updateMut = trpc.orgUnit.update.useMutation({
onSuccess: () => { void utils.orgUnit.getTree.invalidate(); void utils.orgUnit.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const allUnits = (flatList ?? []) as unknown as OrgUnitRow[];
function openCreate(level: number, parentId?: string) {
setEditing({
name: "",
shortName: "",
level,
parentId: parentId ?? "",
sortOrder: 0,
});
setError(null);
}
function openEdit(u: OrgUnitRow) {
setEditing({
id: u.id,
name: u.name,
shortName: u.shortName ?? "",
level: u.level,
parentId: u.parentId ?? "",
sortOrder: u.sortOrder,
});
setError(null);
}
function handleSave() {
if (!editing) return;
setError(null);
if (editing.id) {
updateMut.mutate({
id: editing.id,
data: {
name: editing.name,
shortName: editing.shortName || undefined,
sortOrder: editing.sortOrder,
parentId: editing.parentId || undefined,
},
});
} else {
createMut.mutate({
name: editing.name,
shortName: editing.shortName || undefined,
level: editing.level,
parentId: editing.parentId || undefined,
sortOrder: editing.sortOrder,
});
}
}
// Possible parents for the editing unit (must be lower level number)
const possibleParents = editing
? allUnits.filter((u) => u.level < editing.level && u.isActive)
: [];
const isPending = createMut.isPending || updateMut.isPending;
const treeNodes = (tree ?? []) as unknown as OrgUnitNode[];
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Org Units</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
3-level hierarchy: L5 (Division) L6 (Department) L7 (Team)
</p>
</div>
<div className="flex gap-2">
<button type="button" onClick={() => openCreate(5)} className="px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium">+ L5</button>
<button type="button" onClick={() => openCreate(6)} className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium">+ L6</button>
<button type="button" onClick={() => openCreate(7)} className="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium">+ L7</button>
</div>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{error}
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
</div>
)}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-2">
{isLoading && <div className="text-center py-8 text-gray-400">Loading...</div>}
{!isLoading && treeNodes.length === 0 && (
<div className="text-center py-8 text-gray-400">No org units yet. Start by adding an L5 division.</div>
)}
{treeNodes.map((node) => (
<TreeNode key={node.id} node={node} onEdit={openEdit} />
))}
</div>
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Org Unit" : `Add ${LEVEL_LABELS[editing.level] ?? `L${editing.level}`}`}
</h2>
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
type="text"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
placeholder="e.g. Content Production"
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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Short Name</label>
<input
type="text"
value={editing.shortName}
onChange={(e) => setEditing({ ...editing, shortName: e.target.value })}
placeholder="CP"
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
<input
type="number"
value={editing.sortOrder}
onChange={(e) => setEditing({ ...editing, sortOrder: parseInt(e.target.value) || 0 })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
{editing.level > 5 && (
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Unit</label>
<select
value={editing.parentId}
onChange={(e) => setEditing({ ...editing, parentId: e.target.value })}
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"
>
<option value=""> No parent </option>
{possibleParents.map((p) => (
<option key={p.id} value={p.id}>
L{p.level} {p.name}
</option>
))}
</select>
</div>
)}
<div>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${LEVEL_COLORS[editing.level] ?? "bg-gray-100 text-gray-600"}`}>
{LEVEL_LABELS[editing.level] ?? `Level ${editing.level}`}
</span>
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditing(null)} 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={handleSave}
disabled={isPending || !editing.name}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,788 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
// ─── Local types ────────────────────────────────────────────────────────────
type RateCardRow = {
id: string;
name: string;
currency: string;
effectiveFrom: string | null;
effectiveTo: string | null;
source: string | null;
isActive: boolean;
clientId: string | null;
client: { id: string; name: string; code: string | null } | null;
_count: { lines: number };
};
type RateCardLine = {
id: string;
rateCardId: string;
roleId: string | null;
chapter: string | null;
location: string | null;
seniority: string | null;
workType: string | null;
serviceGroup: string | null;
costRateCents: number;
billRateCents: number | null;
machineRateCents: number | null;
attributes: unknown;
role: { id: string; name: string; color: string | null } | null;
createdAt: string;
updatedAt: string;
};
type ClientOption = {
id: string;
name: string;
code: string | null;
};
type EditingCard = {
id?: string;
name: string;
currency: string;
effectiveFrom: string;
effectiveTo: string;
source: string;
clientId: string;
};
type EditingLine = {
id?: string;
roleId: string;
chapter: string;
location: string;
seniority: string;
workType: string;
serviceGroup: string;
costRateCents: number;
billRateCents: number;
machineRateCents: number;
};
const emptyCard: EditingCard = {
name: "",
currency: "EUR",
effectiveFrom: "",
effectiveTo: "",
source: "",
clientId: "",
};
const emptyLine: EditingLine = {
roleId: "",
chapter: "",
location: "",
seniority: "",
workType: "",
serviceGroup: "",
costRateCents: 0,
billRateCents: 0,
machineRateCents: 0,
};
function formatCents(cents: number | null | undefined): string {
if (cents == null) return "-";
return (cents / 100).toFixed(2);
}
function formatDate(d: string | null | undefined): string {
if (!d) return "-";
return new Date(d).toLocaleDateString("de-DE");
}
// ─── Component ──────────────────────────────────────────────────────────────
export function RateCardsClient() {
const [search, setSearch] = useState("");
const [filterClientId, setFilterClientId] = useState<string>("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [editingCard, setEditingCard] = useState<EditingCard | null>(null);
const [editingLine, setEditingLine] = useState<EditingLine | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
// ─── Queries ────────────────────────────────────────────────────────────
const { data: cards, isLoading } = trpc.rateCard.list.useQuery(
{
search: search || undefined,
...(filterClientId ? { clientId: filterClientId } : {}),
},
);
const { data: detail } = trpc.rateCard.getById.useQuery(
{ id: selectedId! },
{ enabled: !!selectedId },
);
const { data: roles } = trpc.role.list.useQuery({});
const { data: clientsData } = trpc.clientEntity.list.useQuery({});
// ─── Mutations ──────────────────────────────────────────────────────────
const invalidateAll = () => {
void utils.rateCard.list.invalidate();
if (selectedId) void utils.rateCard.getById.invalidate({ id: selectedId });
};
// Use bare useMutation() to avoid TS2589 deep inference (see LEARNINGS.md)
const createMut = trpc.rateCard.create.useMutation();
const updateMut = trpc.rateCard.update.useMutation();
const deactivateMut = trpc.rateCard.deactivate.useMutation();
const addLineMut = trpc.rateCard.addLine.useMutation();
const updateLineMut = trpc.rateCard.updateLine.useMutation();
const deleteLineMut = trpc.rateCard.deleteLine.useMutation();
// ─── Handlers ───────────────────────────────────────────────────────────
function openCreateCard() {
setEditingCard({ ...emptyCard });
setError(null);
}
function openEditCard() {
if (!detail) return;
setEditingCard({
id: detail.id,
name: detail.name,
currency: detail.currency,
effectiveFrom: detail.effectiveFrom
? new Date(detail.effectiveFrom).toISOString().slice(0, 10)
: "",
effectiveTo: detail.effectiveTo
? new Date(detail.effectiveTo).toISOString().slice(0, 10)
: "",
source: detail.source ?? "",
clientId: detail.clientId ?? "",
});
setError(null);
}
async function handleSaveCard() {
if (!editingCard) return;
setError(null);
try {
if (editingCard.id) {
await updateMut.mutateAsync({
id: editingCard.id,
data: {
name: editingCard.name,
currency: editingCard.currency,
...(editingCard.effectiveFrom
? { effectiveFrom: new Date(editingCard.effectiveFrom) }
: { effectiveFrom: null }),
...(editingCard.effectiveTo
? { effectiveTo: new Date(editingCard.effectiveTo) }
: { effectiveTo: null }),
source: editingCard.source || null,
clientId: editingCard.clientId || null,
},
});
invalidateAll();
setEditingCard(null);
} else {
const created = await createMut.mutateAsync({
name: editingCard.name,
currency: editingCard.currency,
...(editingCard.effectiveFrom
? { effectiveFrom: new Date(editingCard.effectiveFrom) }
: {}),
...(editingCard.effectiveTo
? { effectiveTo: new Date(editingCard.effectiveTo) }
: {}),
...(editingCard.source ? { source: editingCard.source } : {}),
...(editingCard.clientId ? { clientId: editingCard.clientId } : {}),
lines: [],
});
invalidateAll();
setEditingCard(null);
setSelectedId(created.id);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save rate card");
}
}
function openAddLine() {
setEditingLine({ ...emptyLine });
setError(null);
}
function openEditLine(line: RateCardLine) {
setEditingLine({
id: line.id,
roleId: line.roleId ?? "",
chapter: line.chapter ?? "",
location: line.location ?? "",
seniority: line.seniority ?? "",
workType: line.workType ?? "",
serviceGroup: line.serviceGroup ?? "",
costRateCents: line.costRateCents,
billRateCents: line.billRateCents ?? 0,
machineRateCents: line.machineRateCents ?? 0,
});
setError(null);
}
async function handleSaveLine() {
if (!editingLine || !selectedId) return;
setError(null);
const lineData = {
...(editingLine.roleId ? { roleId: editingLine.roleId } : {}),
...(editingLine.chapter ? { chapter: editingLine.chapter } : {}),
...(editingLine.location ? { location: editingLine.location } : {}),
...(editingLine.seniority ? { seniority: editingLine.seniority } : {}),
...(editingLine.workType ? { workType: editingLine.workType } : {}),
...(editingLine.serviceGroup ? { serviceGroup: editingLine.serviceGroup } : {}),
costRateCents: editingLine.costRateCents,
...(editingLine.billRateCents ? { billRateCents: editingLine.billRateCents } : {}),
...(editingLine.machineRateCents ? { machineRateCents: editingLine.machineRateCents } : {}),
attributes: {},
};
try {
if (editingLine.id) {
await updateLineMut.mutateAsync({ lineId: editingLine.id, data: lineData });
} else {
await addLineMut.mutateAsync({ rateCardId: selectedId, line: lineData });
}
invalidateAll();
setEditingLine(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save rate line");
}
}
async function handleDeleteLine(lineId: string) {
if (!confirm("Delete this rate line?")) return;
try {
await deleteLineMut.mutateAsync({ lineId });
invalidateAll();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to delete rate line");
}
}
async function handleDeactivate(id: string) {
if (!confirm("Deactivate this rate card?")) return;
try {
await deactivateMut.mutateAsync({ id });
invalidateAll();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to deactivate rate card");
}
}
async function handleReactivate(id: string) {
try {
await updateMut.mutateAsync({ id, data: { isActive: true } });
invalidateAll();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to reactivate rate card");
}
}
const isPending =
createMut.isPending ||
updateMut.isPending ||
addLineMut.isPending ||
updateLineMut.isPending ||
deleteLineMut.isPending;
const cardList = (cards ?? []) as unknown as RateCardRow[];
const lines = ((detail?.lines ?? []) as unknown as RateCardLine[]);
const roleList = (roles ?? []) as unknown as { id: string; name: string; color: string | null }[];
const clientList = (clientsData ?? []) as unknown as ClientOption[];
// ─── Render ─────────────────────────────────────────────────────────────
return (
<div className="p-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Rate Cards</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage cost and billing rates per role, chapter, and seniority
</p>
</div>
<button
type="button"
onClick={openCreateCard}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
+ New Rate Card
</button>
</div>
{/* Error banner */}
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{error}
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
</div>
)}
<div className="flex gap-6">
{/* ─── Left: Card list ─────────────────────────────────────────── */}
<div className="w-80 shrink-0">
<div className="mb-3 space-y-2">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search rate cards..."
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-full bg-white dark:bg-gray-900 dark:text-gray-100"
/>
<select
value={filterClientId}
onChange={(e) => setFilterClientId(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-full bg-white dark:bg-gray-900 dark:text-gray-100"
>
<option value="">All clients</option>
{clientList.map((c) => (
<option key={c.id} value={c.id}>
{c.code ? `${c.code}${c.name}` : c.name}
</option>
))}
</select>
</div>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800">
{isLoading && <div className="text-center py-8 text-gray-400">Loading...</div>}
{!isLoading && cardList.length === 0 && (
<div className="text-center py-8 text-gray-400">
{search ? "No rate cards match your search." : "No rate cards yet."}
</div>
)}
{cardList.map((card) => (
<button
key={card.id}
type="button"
onClick={() => setSelectedId(card.id)}
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors ${
selectedId === card.id
? "bg-brand-50 dark:bg-brand-900/20 border-l-2 border-brand-600"
: ""
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{card.name}
</span>
{!card.isActive && (
<span className="text-xs text-gray-400 italic ml-2 shrink-0">inactive</span>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400">
<span>{card.currency}</span>
<span>{card._count.lines} lines</span>
{card.effectiveFrom && (
<span>from {formatDate(card.effectiveFrom)}</span>
)}
</div>
{card.client && (
<div className="mt-1 text-xs text-brand-600 dark:text-brand-400">
{card.client.code ? `${card.client.code}${card.client.name}` : card.client.name}
</div>
)}
</button>
))}
</div>
</div>
{/* ─── Right: Detail ───────────────────────────────────────────── */}
<div className="flex-1 min-w-0">
{!selectedId && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center text-gray-400">
Select a rate card to view details, or create a new one.
</div>
)}
{selectedId && detail && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700">
{/* Card header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{detail.name}
</h2>
<div className="flex items-center gap-4 mt-1 text-sm text-gray-500 dark:text-gray-400">
<span>{detail.currency}</span>
{detail.clientId && (detail as unknown as RateCardRow).client && (
<span>Client: {(detail as unknown as RateCardRow).client!.name}</span>
)}
{detail.source && <span>Source: {detail.source}</span>}
<span>
{detail.effectiveFrom
? formatDate(detail.effectiveFrom as unknown as string)
: "Open start"}{" "}
&mdash;{" "}
{detail.effectiveTo
? formatDate(detail.effectiveTo as unknown as string)
: "Open end"}
</span>
{!detail.isActive && (
<span className="text-amber-600 dark:text-amber-400 font-medium">Inactive</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={openEditCard}
className="px-3 py-1.5 text-sm text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300 font-medium"
>
Edit
</button>
{detail.isActive ? (
<button
type="button"
onClick={() => handleDeactivate(detail.id)}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-red-600 font-medium"
>
Deactivate
</button>
) : (
<button
type="button"
onClick={() => handleReactivate(detail.id)}
className="px-3 py-1.5 text-sm text-green-600 hover:text-green-800 font-medium"
>
Reactivate
</button>
)}
</div>
</div>
</div>
{/* Lines header */}
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
Rate Lines ({lines.length})
</h3>
<button
type="button"
onClick={openAddLine}
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-xs font-medium"
>
+ Add Line
</button>
</div>
{/* Lines table */}
<div className="overflow-x-auto">
{lines.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
No rate lines yet. Add one to get started.
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-800">
<th className="px-4 py-2 font-medium">Role</th>
<th className="px-4 py-2 font-medium">Chapter</th>
<th className="px-4 py-2 font-medium">Location</th>
<th className="px-4 py-2 font-medium">Seniority</th>
<th className="px-4 py-2 font-medium">Work Type</th>
<th className="px-4 py-2 font-medium text-right">Cost Rate</th>
<th className="px-4 py-2 font-medium text-right">Bill Rate</th>
<th className="px-4 py-2 font-medium text-right">Machine Rate</th>
<th className="px-4 py-2 font-medium w-20"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{lines.map((line) => (
<tr key={line.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30 group">
<td className="px-4 py-2 text-gray-900 dark:text-gray-100">
{line.role?.name ?? <span className="text-gray-400">-</span>}
</td>
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.chapter || "-"}</td>
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.location || "-"}</td>
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.seniority || "-"}</td>
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.workType || "-"}</td>
<td className="px-4 py-2 text-right font-mono text-gray-900 dark:text-gray-100">
{formatCents(line.costRateCents)}
</td>
<td className="px-4 py-2 text-right font-mono text-gray-700 dark:text-gray-300">
{formatCents(line.billRateCents)}
</td>
<td className="px-4 py-2 text-right font-mono text-gray-700 dark:text-gray-300">
{formatCents(line.machineRateCents)}
</td>
<td className="px-4 py-2">
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity justify-end">
<button
type="button"
onClick={() => openEditLine(line)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
<button
type="button"
onClick={() => handleDeleteLine(line.id)}
className="text-xs text-red-500 hover:text-red-700 font-medium"
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
</div>
{/* ─── Rate Card Modal ─────────────────────────────────────────────── */}
{editingCard && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<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">
{editingCard.id ? "Edit Rate Card" : "New Rate Card"}
</h2>
<button type="button" onClick={() => setEditingCard(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
type="text"
value={editingCard.name}
onChange={(e) => setEditingCard({ ...editingCard, name: e.target.value })}
placeholder="e.g. Standard 2026"
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Client</label>
<select
value={editingCard.clientId}
onChange={(e) => setEditingCard({ ...editingCard, clientId: e.target.value })}
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"
>
<option value="">-- No client --</option>
{clientList.map((c) => (
<option key={c.id} value={c.id}>
{c.code ? `${c.code}${c.name}` : c.name}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Currency</label>
<input
type="text"
value={editingCard.currency}
onChange={(e) => setEditingCard({ ...editingCard, currency: e.target.value.toUpperCase() })}
placeholder="EUR"
maxLength={3}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Source</label>
<input
type="text"
value={editingCard.source}
onChange={(e) => setEditingCard({ ...editingCard, source: e.target.value })}
placeholder="e.g. Finance dept"
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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective From</label>
<input
type="date"
value={editingCard.effectiveFrom}
onChange={(e) => setEditingCard({ ...editingCard, effectiveFrom: e.target.value })}
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective To</label>
<input
type="date"
value={editingCard.effectiveTo}
onChange={(e) => setEditingCard({ ...editingCard, effectiveTo: e.target.value })}
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"
/>
</div>
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditingCard(null)} 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={handleSaveCard}
disabled={isPending || !editingCard.name || editingCard.currency.length !== 3}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : editingCard.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
{/* ─── Rate Line Modal ─────────────────────────────────────────────── */}
{editingLine && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-lg mx-4">
<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">
{editingLine.id ? "Edit Rate Line" : "Add Rate Line"}
</h2>
<button type="button" onClick={() => setEditingLine(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Role</label>
<select
value={editingLine.roleId}
onChange={(e) => setEditingLine({ ...editingLine, roleId: e.target.value })}
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"
>
<option value="">-- No specific role --</option>
{roleList.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Chapter</label>
<input
type="text"
value={editingLine.chapter}
onChange={(e) => setEditingLine({ ...editingLine, chapter: e.target.value })}
placeholder="e.g. Animation"
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Location</label>
<input
type="text"
value={editingLine.location}
onChange={(e) => setEditingLine({ ...editingLine, location: e.target.value })}
placeholder="e.g. Munich"
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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Seniority</label>
<input
type="text"
value={editingLine.seniority}
onChange={(e) => setEditingLine({ ...editingLine, seniority: e.target.value })}
placeholder="e.g. Senior"
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Work Type</label>
<input
type="text"
value={editingLine.workType}
onChange={(e) => setEditingLine({ ...editingLine, workType: e.target.value })}
placeholder="e.g. Onsite"
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"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Service Group</label>
<input
type="text"
value={editingLine.serviceGroup}
onChange={(e) => setEditingLine({ ...editingLine, serviceGroup: e.target.value })}
placeholder="e.g. Post Production"
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"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cost Rate (cents)</label>
<input
type="number"
value={editingLine.costRateCents}
onChange={(e) => setEditingLine({ ...editingLine, costRateCents: parseInt(e.target.value) || 0 })}
min={0}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
<span className="text-xs text-gray-400 mt-0.5 block">= {formatCents(editingLine.costRateCents)}</span>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Bill Rate (cents)</label>
<input
type="number"
value={editingLine.billRateCents}
onChange={(e) => setEditingLine({ ...editingLine, billRateCents: parseInt(e.target.value) || 0 })}
min={0}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
<span className="text-xs text-gray-400 mt-0.5 block">= {formatCents(editingLine.billRateCents)}</span>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Machine Rate (cents)</label>
<input
type="number"
value={editingLine.machineRateCents}
onChange={(e) => setEditingLine({ ...editingLine, machineRateCents: parseInt(e.target.value) || 0 })}
min={0}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
<span className="text-xs text-gray-400 mt-0.5 block">= {formatCents(editingLine.machineRateCents)}</span>
</div>
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditingLine(null)} 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={handleSaveLine}
disabled={isPending || editingLine.costRateCents <= 0}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : editingLine.id ? "Update Line" : "Add Line"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,928 @@
"use client";
import { useState, useEffect } from "react";
import { trpc } from "~/lib/trpc/client.js";
const INPUT_CLASS =
"w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100";
const LABEL_CLASS = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
type Provider = "openai" | "azure";
const ALL_ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const;
type SystemRole = typeof ALL_ROLES[number];
interface ScoreWeights {
skillDepth: number;
skillBreadth: number;
costEfficiency: number;
chargeability: number;
experience: number;
}
type ParsedAzureUrl = {
endpoint: string;
apiVersion: string;
deployment: string | null; // null for Responses API URLs (deployment not in path)
urlType: "completions" | "responses";
};
/** Parse endpoint, deployment, and api-version out of an Azure URL.
* Supports both Chat Completions and Responses API formats. */
function parseAzureUrl(raw: string): ParsedAzureUrl | null {
try {
const url = new URL(raw);
const endpoint = `${url.protocol}//${url.host}`;
const apiVersion = url.searchParams.get("api-version") ?? "2025-01-01-preview";
// Chat Completions: /openai/deployments/{name}/chat/completions
const completionsMatch = url.pathname.match(/\/openai\/deployments\/([^/]+)\//);
if (completionsMatch) {
return { endpoint, apiVersion, deployment: completionsMatch[1]!, urlType: "completions" };
}
// Responses API: /openai/responses
if (url.pathname.includes("/openai/responses")) {
return { endpoint, apiVersion, deployment: null, urlType: "responses" };
}
return null;
} catch {
return null;
}
}
export function SystemSettingsClient() {
const [provider, setProvider] = useState<Provider>("openai");
const [endpoint, setEndpoint] = useState("");
const [model, setModel] = useState("");
const [apiVersion, setApiVersion] = useState("2025-01-01-preview");
const [apiKey, setApiKey] = useState("");
const [maxTokens, setMaxTokens] = useState(2000);
const [temperature, setTemperature] = useState(1);
const [summaryPrompt, setSummaryPrompt] = useState("");
const [saved, setSaved] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; error?: string; raw?: string | null } | null>(null);
const [urlPasteValue, setUrlPasteValue] = useState("");
const [urlParseError, setUrlParseError] = useState(false);
const [urlParsedType, setUrlParsedType] = useState<"completions" | "responses" | null>(null);
// Value Score settings
const [scoreWeights, setScoreWeights] = useState<ScoreWeights>({
skillDepth: 0.30,
skillBreadth: 0.15,
costEfficiency: 0.25,
chargeability: 0.15,
experience: 0.15,
});
const [scoreVisibleRoles, setScoreVisibleRoles] = useState<SystemRole[]>(["ADMIN", "MANAGER"]);
const [scoreSaved, setScoreSaved] = useState(false);
const [recomputeResult, setRecomputeResult] = useState<{ updated: number } | null>(null);
// SMTP settings
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState(587);
const [smtpUser, setSmtpUser] = useState("");
const [smtpPassword, setSmtpPassword] = useState("");
const [smtpFrom, setSmtpFrom] = useState("");
const [smtpTls, setSmtpTls] = useState(true);
const [smtpSaved, setSmtpSaved] = useState(false);
const [smtpTestResult, setSmtpTestResult] = useState<{ ok: boolean; error?: string } | null>(null);
// Vacation defaults
const [vacationDefaultDays, setVacationDefaultDays] = useState(28);
const [vacationSaved, setVacationSaved] = useState(false);
const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, {
staleTime: 0,
});
useEffect(() => {
if (settings) {
setProvider((settings.aiProvider ?? "openai") as Provider);
setEndpoint(settings.azureOpenAiEndpoint ?? "");
setModel(settings.azureOpenAiDeployment ?? "");
setApiVersion(settings.azureApiVersion ?? "2025-01-01-preview");
setMaxTokens(settings.aiMaxCompletionTokens ?? 2000);
setTemperature(settings.aiTemperature ?? 1);
setSummaryPrompt(settings.aiSummaryPrompt ?? "");
if (settings.scoreWeights) {
setScoreWeights(settings.scoreWeights as ScoreWeights);
}
if (settings.scoreVisibleRoles) {
setScoreVisibleRoles(settings.scoreVisibleRoles as SystemRole[]);
}
// SMTP
setSmtpHost(settings.smtpHost ?? "");
setSmtpPort(settings.smtpPort ?? 587);
setSmtpUser(settings.smtpUser ?? "");
setSmtpFrom(settings.smtpFrom ?? "");
setSmtpTls(settings.smtpTls ?? true);
// Vacation
setVacationDefaultDays(settings.vacationDefaultDays ?? 28);
}
}, [settings]);
function handleUrlPaste(raw: string) {
setUrlPasteValue(raw);
if (!raw) { setUrlParseError(false); setUrlParsedType(null); return; }
const parsed = parseAzureUrl(raw);
if (parsed) {
setEndpoint(parsed.endpoint);
setApiVersion(parsed.apiVersion);
if (parsed.deployment) setModel(parsed.deployment);
setUrlParseError(false);
setUrlParsedType(parsed.urlType);
setUrlPasteValue("");
} else {
setUrlParseError(true);
setUrlParsedType(null);
}
}
const updateMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setSaved(true);
setTestResult(null);
setTimeout(() => setSaved(false), 3000);
},
});
const testMutation = trpc.settings.testAiConnection.useMutation({
onSuccess: (data) => setTestResult(data),
onError: (err) => setTestResult({ ok: false, error: err.message }),
});
const saveScoreMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setScoreSaved(true);
setTimeout(() => setScoreSaved(false), 3000);
},
});
const recomputeMutation = trpc.resource.recomputeValueScores.useMutation({
onSuccess: (data) => setRecomputeResult(data),
});
const saveSmtpMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setSmtpSaved(true);
setSmtpTestResult(null);
setTimeout(() => setSmtpSaved(false), 3000);
},
});
const testSmtpMutation = trpc.settings.testSmtpConnection.useMutation({
onSuccess: (data) => setSmtpTestResult(data),
onError: (err) => setSmtpTestResult({ ok: false, error: err.message }),
});
const saveVacationMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setVacationSaved(true);
setTimeout(() => setVacationSaved(false), 3000);
},
});
function handleSaveSmtp() {
saveSmtpMutation.mutate({
smtpHost: smtpHost || undefined,
smtpPort,
smtpUser: smtpUser || undefined,
...(smtpPassword ? { smtpPassword } : {}),
smtpFrom: smtpFrom || undefined,
smtpTls,
});
}
function handleSaveVacation() {
saveVacationMutation.mutate({ vacationDefaultDays });
}
function handleSaveScoreSettings() {
saveScoreMutation.mutate({ scoreWeights, scoreVisibleRoles });
}
function updateWeight(key: keyof ScoreWeights, value: number) {
setScoreWeights((prev) => ({ ...prev, [key]: value }));
}
function toggleRole(role: SystemRole) {
setScoreVisibleRoles((prev) =>
prev.includes(role) ? prev.filter((r) => r !== role) : [...prev, role],
);
}
const weightSum = Object.values(scoreWeights).reduce((s, v) => s + v, 0);
const weightSumOk = Math.abs(weightSum - 1.0) < 0.01;
function handleSave() {
updateMutation.mutate({
aiProvider: provider,
azureOpenAiEndpoint: provider === "azure" ? endpoint : "",
azureOpenAiDeployment: model,
azureApiVersion: provider === "azure" ? apiVersion : undefined,
aiMaxCompletionTokens: maxTokens,
aiTemperature: temperature,
aiSummaryPrompt: summaryPrompt || undefined,
...(apiKey ? { azureOpenAiApiKey: apiKey } : {}),
});
}
if (isLoading) {
return <div className="p-6 animate-pulse"><div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" /></div>;
}
return (
<div className="p-4 sm:p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">System Settings</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Configure AI integration for skill profile generation.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wider">AI Provider</h2>
{/* Provider toggle */}
<div>
<label className={LABEL_CLASS}>Provider</label>
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-gray-600 w-fit">
<button
type="button"
onClick={() => { setProvider("openai"); setTestResult(null); }}
className={`px-4 py-2 text-sm font-medium transition-colors ${
provider === "openai"
? "bg-brand-600 text-white"
: "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600"
}`}
>
OpenAI
</button>
<button
type="button"
onClick={() => { setProvider("azure"); setTestResult(null); }}
className={`px-4 py-2 text-sm font-medium border-l border-gray-200 dark:border-gray-600 transition-colors ${
provider === "azure"
? "bg-brand-600 text-white"
: "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600"
}`}
>
Azure OpenAI
</button>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1.5">
{provider === "openai"
? "Use a standard OpenAI API key from platform.openai.com."
: "Use a deployment on your own Azure OpenAI resource."}
</p>
</div>
{/* Azure-only fields */}
{provider === "azure" && (
<>
{/* Paste full URL shortcut */}
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 px-4 py-3 space-y-2">
<p className="text-xs font-medium text-blue-800 dark:text-blue-300">
Paste a full completion URL to auto-fill all fields below:
</p>
<input
type="url"
className={`${INPUT_CLASS} border-blue-300 dark:border-blue-600`}
placeholder="https://…cognitiveservices.azure.com/openai/deployments/gpt-5/chat/completions?api-version=2025-01-01-preview"
value={urlPasteValue}
onChange={(e) => handleUrlPaste(e.target.value)}
/>
{urlParseError && (
<p className="text-xs text-red-600 dark:text-red-400">
Could not parse URL expected either a Chat Completions URL
(<code className="font-mono">/openai/deployments//chat/completions</code>)
or a Responses API URL (<code className="font-mono">/openai/responses</code>).
</p>
)}
{urlParsedType === "responses" && (
<p className="text-xs text-amber-700 dark:text-amber-400">
Responses API URL detected endpoint and api-version filled in.
Enter the <strong>deployment/model name</strong> manually below (it is not part of this URL).
</p>
)}
{urlParsedType === "completions" && (
<p className="text-xs text-green-700 dark:text-green-400">
All fields filled from URL.
</p>
)}
</div>
<div>
<label className={LABEL_CLASS} htmlFor="ai-endpoint">Endpoint (base URL)</label>
<input
id="ai-endpoint"
type="url"
className={INPUT_CLASS}
placeholder="https://myinstance.cognitiveservices.azure.com"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Everything up to (not including) <code className="font-mono">/openai/</code>
</p>
</div>
</>
)}
{/* Model / deployment name */}
<div>
<label className={LABEL_CLASS} htmlFor="ai-model">
{provider === "azure" ? "Deployment Name" : "Model Name"}
</label>
<input
id="ai-model"
type="text"
className={INPUT_CLASS}
placeholder={provider === "azure" ? "my-gpt4o-deployment" : "gpt-4o-mini"}
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{provider === "azure"
? "The deployment name you chose when deploying the model in Azure."
: "The model identifier, e.g. gpt-4o-mini, gpt-4o, gpt-3.5-turbo."}
</p>
</div>
{/* Azure-only: api version */}
{provider === "azure" && (
<div>
<label className={LABEL_CLASS} htmlFor="ai-api-version">API Version</label>
<input
id="ai-api-version"
type="text"
className={INPUT_CLASS}
placeholder="2025-01-01-preview"
value={apiVersion}
onChange={(e) => setApiVersion(e.target.value)}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
The <code className="font-mono">api-version</code> query parameter from your endpoint URL. Default: <code className="font-mono">2025-01-01-preview</code>
</p>
</div>
)}
{/* API key */}
<div>
<label className={LABEL_CLASS} htmlFor="ai-key">API Key</label>
<input
id="ai-key"
type="password"
className={INPUT_CLASS}
placeholder={
settings?.hasApiKey
? "●●●●●●●●●●●● (already set — enter new value to replace)"
: provider === "openai"
? "sk-..."
: "Enter Azure API key"
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
autoComplete="new-password"
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{provider === "openai"
? "Your secret key from platform.openai.com → API keys. Starts with sk-."
: "One of the two keys from Azure Portal → your resource → Keys and Endpoint."}
{settings?.hasApiKey && " Leave blank to keep the existing key."}
</p>
</div>
{/* Test result */}
{testResult && (
<div
className={`rounded-lg px-4 py-3 text-sm ${
testResult.ok
? "bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 text-green-700 dark:text-green-300"
: "bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-300"
}`}
>
{testResult.ok ? (
<span className="font-medium">Connection successful AI summaries are ready to use.</span>
) : (
<div className="space-y-2">
<p><span className="font-medium">Connection failed:</span> {testResult.error}</p>
{testResult.raw && (
<details className="text-xs">
<summary className="cursor-pointer opacity-70 hover:opacity-100">Show raw error</summary>
<pre className="mt-1 p-2 bg-red-100 dark:bg-red-950 rounded text-red-800 dark:text-red-200 whitespace-pre-wrap break-all font-mono">
{testResult.raw}
</pre>
</details>
)}
</div>
)}
</div>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleSave}
disabled={updateMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{updateMutation.isPending ? "Saving…" : "Save Settings"}
</button>
<button
type="button"
onClick={() => { setTestResult(null); testMutation.mutate(); }}
disabled={testMutation.isPending}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm font-medium disabled:opacity-50"
>
{testMutation.isPending ? "Testing…" : "Test Connection"}
</button>
{saved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
</div>
</div>
{/* Generation settings */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wider">Generation Settings</h2>
{/* Max completion tokens */}
<div>
<div className="flex items-center justify-between mb-1">
<label className={LABEL_CLASS} htmlFor="ai-max-tokens">Max Completion Tokens</label>
<span className="text-sm font-mono text-brand-600 dark:text-brand-400">{maxTokens}</span>
</div>
<input
id="ai-max-tokens"
type="range"
min={50}
max={2000}
step={50}
value={maxTokens}
onChange={(e) => setMaxTokens(Number(e.target.value))}
className="w-full accent-brand-600"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>500</span>
<span className="text-gray-500 dark:text-gray-400">
{maxTokens < 1000 ? "⚠ May be empty for reasoning models (GPT-5, o1, o3)" :
maxTokens <= 2000 ? "Recommended for reasoning models ✓" :
maxTokens <= 4000 ? "High — allows longer bios" :
"Very high"}
</span>
<span>16000</span>
</div>
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">
Reasoning models (GPT-5, o1, o3) consume tokens internally before writing output. Set to at least 2000 to avoid empty responses.
</p>
</div>
{/* Temperature */}
<div>
<div className="flex items-center justify-between mb-1">
<label className={LABEL_CLASS} htmlFor="ai-temperature">Temperature</label>
<span className="text-sm font-mono text-brand-600 dark:text-brand-400">{temperature.toFixed(1)}</span>
</div>
<input
id="ai-temperature"
type="range"
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(e) => setTemperature(Number(e.target.value))}
className="w-full accent-brand-600"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>0 deterministic</span>
<span className="text-gray-500 dark:text-gray-400">
{temperature <= 0.3 ? "Factual & consistent" :
temperature <= 0.9 ? "Balanced" :
temperature <= 1.0 ? "Default (1) — recommended ✓" :
temperature <= 1.2 ? "Creative" :
"Very creative / unpredictable"}
</span>
<span>2 creative</span>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Some models (e.g. GPT-5) only accept the default value of 1. If generation fails with a temperature error, the system retries automatically without it.
</p>
</div>
{/* Summary prompt */}
<div>
<div className="flex items-center justify-between mb-1">
<label className={LABEL_CLASS} htmlFor="ai-prompt">Profile Summary Prompt</label>
{summaryPrompt && (
<button
type="button"
onClick={() => setSummaryPrompt("")}
className="text-xs text-brand-600 hover:underline"
>
Reset to default
</button>
)}
</div>
<textarea
id="ai-prompt"
rows={10}
value={summaryPrompt || (settings?.defaultSummaryPrompt ?? "")}
onChange={(e) => setSummaryPrompt(e.target.value)}
className={`${INPUT_CLASS} font-mono text-xs leading-relaxed resize-y`}
spellCheck={false}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Available placeholders: <code className="font-mono">{"{role}"}</code> <code className="font-mono">{"{chapter}"}</code> <code className="font-mono">{"{mainSkills}"}</code> <code className="font-mono">{"{topSkills}"}</code>
</p>
</div>
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={handleSave}
disabled={updateMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{updateMutation.isPending ? "Saving…" : "Save Settings"}
</button>
{saved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
</div>
</div>
</div>{/* end 2-col grid */}
{/* Value Score Settings */}
<div className="mt-6 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wider mb-1">Value Score</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
A persistent 0100 <em>price/quality</em> metric per resource five weighted dimensions combined.
Recompute on demand after changing weights or importing new skill matrices.
</p>
</div>
<div className="flex-shrink-0 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 px-3 py-2 font-mono text-[11px] text-gray-700 dark:text-gray-300 whitespace-nowrap">
round(<span className="text-brand-600 dark:text-brand-400">D</span>·w +{" "}
<span className="text-brand-600 dark:text-brand-400">B</span>·w +{" "}
<span className="text-brand-600 dark:text-brand-400">C</span>·w +{" "}
<span className="text-brand-600 dark:text-brand-400">A</span>·w +{" "}
<span className="text-brand-600 dark:text-brand-400">E</span>·w)
</div>
</div>
{/* Weight sliders — compact grid */}
<div>
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Dimension Weights must sum to 100%
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3">
{/* Skill Depth */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">D</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Skill Depth</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.skillDepth * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.skillDepth * 100)}
onChange={(e) => updateWeight("skillDepth", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Average proficiency (15) across all skills, scaled to 0100. Expert-heavy profiles score near 100.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
D = round((avg_proficiency / 5) × 100)
<span className="ml-2 text-gray-400">avg 4.0/5 80</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = all Beginner · 100 = all Expert
</div>
</div>
</details>
</div>
{/* Skill Breadth */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">B</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Skill Breadth</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.skillBreadth * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.skillBreadth * 100)}
onChange={(e) => updateWeight("skillBreadth", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Number of distinct skills listed. 10 pts per skill, caps at 100 (10+ skills). Rewards versatile generalists.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
B = min(100, skill_count × 10)
<span className="ml-2 text-gray-400">7 skills 70</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = no skills · 100 = 10+ skills
</div>
</div>
</details>
</div>
{/* Cost Efficiency */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">C</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Cost Efficiency</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.costEfficiency * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.costEfficiency * 100)}
onChange={(e) => updateWeight("costEfficiency", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Inverse LCR vs org-wide max. Cheapest resource = 100, most expensive = 0. Core "price" component.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
C = round((1 LCR / max_LCR) × 100)
<span className="ml-2 text-gray-400">60 vs 120 50</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = highest LCR · 100 = lowest LCR
</div>
<p className="text-[11px] text-amber-600 dark:text-amber-500">
If all resources share the same LCR, everyone scores 0 on this dimension.
</p>
</div>
</details>
</div>
{/* Chargeability */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">A</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Chargeability</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.chargeability * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.chargeability * 100)}
onChange={(e) => updateWeight("chargeability", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Distance from personal chargeability target (90-day window). On target = 100; 2 pts per pp off.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
A = max(0, 100 |target% actual%| × 2)
<span className="ml-2 text-gray-400">10 pp off 80</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = 50+ pp off target · 100 = exactly on target
</div>
<p className="text-[11px] text-gray-400 dark:text-gray-500">
New resources with no allocations: actual = 0%, score reflects gap from target.
</p>
</div>
</details>
</div>
{/* Experience */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">E</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Experience</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.experience * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.experience * 100)}
onChange={(e) => updateWeight("experience", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Average years of experience across skills with explicit years data from skill-matrix imports. Capped at 10 years.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
E = min(100, avg_years × 10)
<span className="ml-2 text-gray-400">6.5 yrs 65 · 10+ yrs 100</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = no years data · 100 = 10+ years average
</div>
</div>
</details>
</div>
</div>
</div>
{/* Weight sum indicator */}
<div className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border ${
weightSumOk
? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700 text-green-700 dark:text-green-300"
: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-700 text-red-700 dark:text-red-300"
}`}>
<span>{weightSumOk ? "✓" : "✗"}</span>
<span>
Total weight: <span className="font-mono">{Math.round(weightSum * 100)}%</span>
{weightSumOk ? " — valid" : " — must be exactly 100% to save"}
</span>
</div>
{/* Visibility roles */}
<div className="space-y-2">
<label className={LABEL_CLASS}>Score visibility which roles can see the Value Score</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Controls who sees the Score column on the Resources list, the breakdown on the Resource Detail page,
and the Top Value Resources dashboard widget.
</p>
<div className="flex flex-wrap gap-3 mt-2">
{ALL_ROLES.map((role) => (
<label key={role} className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none">
<input
type="checkbox"
checked={scoreVisibleRoles.includes(role)}
onChange={() => toggleRole(role)}
className="rounded border-gray-300"
/>
{role}
</label>
))}
</div>
</div>
{/* Recompute */}
<div className="border-t border-gray-100 dark:border-gray-700 pt-5 space-y-3">
<div>
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">Recompute Scores</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Scores are <strong>not updated automatically</strong> run this after changing weights or after importing
new skill matrices. The computation fetches all active resources and their last 90 days of allocations,
then writes the result back to each resource record.
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => { setRecomputeResult(null); recomputeMutation.mutate(); }}
disabled={recomputeMutation.isPending}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm font-medium disabled:opacity-50"
>
{recomputeMutation.isPending ? "Recomputing…" : "Recompute All Scores"}
</button>
{recomputeResult && (
<span className="text-sm text-green-600 dark:text-green-400 font-medium">
Updated {recomputeResult.updated} resource{recomputeResult.updated !== 1 ? "s" : ""}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={handleSaveScoreSettings}
disabled={saveScoreMutation.isPending || !weightSumOk}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{saveScoreMutation.isPending ? "Saving…" : "Save Score Settings"}
</button>
{scoreSaved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
</div>
</div>
{/* ── SMTP / Email ──────────────────────────────────────────── */}
<div className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Email Notifications (SMTP)</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Used to send email notifications when vacation requests are approved or rejected.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS}>SMTP Host</label>
<input type="text" className={INPUT_CLASS} value={smtpHost} onChange={(e) => setSmtpHost(e.target.value)} placeholder="smtp.example.com" />
</div>
<div>
<label className={LABEL_CLASS}>SMTP Port</label>
<input type="number" className={INPUT_CLASS} value={smtpPort} onChange={(e) => setSmtpPort(parseInt(e.target.value, 10))} min={1} max={65535} />
</div>
<div>
<label className={LABEL_CLASS}>SMTP Username</label>
<input type="text" className={INPUT_CLASS} value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} placeholder="user@example.com" autoComplete="off" />
</div>
<div>
<label className={LABEL_CLASS}>
SMTP Password{" "}
{settings?.hasSmtpPassword && <span className="text-gray-400 font-normal text-xs">(set leave blank to keep)</span>}
</label>
<input type="password" className={INPUT_CLASS} value={smtpPassword} onChange={(e) => setSmtpPassword(e.target.value)} placeholder="••••••••" autoComplete="new-password" />
</div>
<div>
<label className={LABEL_CLASS}>From Address</label>
<input type="email" className={INPUT_CLASS} value={smtpFrom} onChange={(e) => setSmtpFrom(e.target.value)} placeholder="noreply@planarchy.app" />
</div>
<div className="flex items-center gap-2 pt-6">
<input type="checkbox" id="smtpTls" checked={smtpTls} onChange={(e) => setSmtpTls(e.target.checked)} className="rounded border-gray-300 text-brand-600" />
<label htmlFor="smtpTls" className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer">Use TLS</label>
</div>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSaveSmtp}
disabled={saveSmtpMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{saveSmtpMutation.isPending ? "Saving…" : "Save SMTP Settings"}
</button>
<button
type="button"
onClick={() => testSmtpMutation.mutate()}
disabled={testSmtpMutation.isPending}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm font-medium disabled:opacity-50"
>
{testSmtpMutation.isPending ? "Testing…" : "Test Connection"}
</button>
{smtpSaved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
{smtpTestResult && (
<span className={`text-sm font-medium ${smtpTestResult.ok ? "text-green-600 dark:text-green-400" : "text-red-500 dark:text-red-400"}`}>
{smtpTestResult.ok ? "✓ Connection successful" : `${smtpTestResult.error}`}
</span>
)}
</div>
</div>
{/* ── Vacation Defaults ─────────────────────────────────────── */}
<div className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Vacation Defaults</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Default annual leave entitlement for new resources and the entitlement bulk-set tool.
</p>
</div>
<div className="max-w-xs">
<label className={LABEL_CLASS}>Default Annual Leave Days</label>
<input
type="number"
className={INPUT_CLASS}
value={vacationDefaultDays}
onChange={(e) => setVacationDefaultDays(parseInt(e.target.value, 10))}
min={0}
max={365}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Applied when creating new entitlement records for resources.</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSaveVacation}
disabled={saveVacationMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{saveVacationMutation.isPending ? "Saving…" : "Save Vacation Settings"}
</button>
{vacationSaved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
</div>
</div>
</div>
);
}
@@ -0,0 +1,536 @@
"use client";
import { useState } from "react";
import { SystemRole, PermissionKey, type PermissionOverrides } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
const PERMISSION_LABELS: Record<string, string> = {
viewCosts: "View Costs",
exportData: "Export Data",
importData: "Import Data",
approveVacations: "Approve Vacations",
manageBlueprints: "Manage Blueprints",
viewAllResources: "View All Resources",
manageResources: "Manage Resources",
manageProjects: "Manage Projects",
manageAllocations: "Manage Allocations",
manageRoles: "Manage Roles",
manageUsers: "Manage Users",
};
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
[SystemRole.ADMIN]: "Admin",
[SystemRole.MANAGER]: "Manager",
[SystemRole.CONTROLLER]: "Controller",
[SystemRole.USER]: "User",
[SystemRole.VIEWER]: "Viewer",
};
const ROLE_BADGE_COLORS: Record<SystemRole, string> = {
[SystemRole.ADMIN]: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400",
[SystemRole.MANAGER]: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
[SystemRole.CONTROLLER]: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400",
[SystemRole.USER]: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
[SystemRole.VIEWER]: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500",
};
// Lower = more privileged (sort asc = most privileged first)
const ROLE_ORDER: Record<string, number> = {
ADMIN: 0,
MANAGER: 1,
CONTROLLER: 2,
USER: 3,
VIEWER: 4,
};
type UserRow = {
id: string;
name: string | null;
email: string;
systemRole: string;
createdAt: Date;
};
type EditState = {
userId: string;
systemRole: SystemRole;
granted: Set<string>;
denied: Set<string>;
chapterIds: string;
};
export function UsersClient() {
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [editState, setEditState] = useState<EditState | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
const utils = trpc.useUtils();
const { data: users, isLoading } = trpc.user.list.useQuery(undefined, {
staleTime: 10_000,
});
const { data: effectivePerms } = trpc.user.getEffectivePermissions.useQuery(
{ userId: selectedUserId ?? "" },
{ enabled: !!selectedUserId },
);
const updateRoleMutation = trpc.user.updateRole.useMutation({
onSuccess: async () => {
await utils.user.list.invalidate();
await utils.user.getEffectivePermissions.invalidate();
},
onError: (err) => setActionError(err.message),
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TS2589: tRPC infers union type too deeply for nullable overrides schema
const setPermissionsMutation = trpc.user.setPermissions.useMutation({
onSuccess: async () => {
await utils.user.list.invalidate();
await utils.user.getEffectivePermissions.invalidate();
},
onError: (err) => setActionError(err.message),
});
const resetPermissionsMutation = trpc.user.resetPermissions.useMutation({
onSuccess: async () => {
await utils.user.list.invalidate();
await utils.user.getEffectivePermissions.invalidate();
if (editState) {
setEditState({ ...editState, granted: new Set(), denied: new Set(), chapterIds: "" });
}
},
onError: (err) => setActionError(err.message),
});
function openEdit(user: UserRow) {
const role = (user.systemRole as SystemRole) ?? SystemRole.USER;
setSelectedUserId(user.id);
setEditState({
userId: user.id,
systemRole: role,
granted: new Set(),
denied: new Set(),
chapterIds: "",
});
setActionError(null);
}
function closeEdit() {
setSelectedUserId(null);
setEditState(null);
setActionError(null);
}
function toggleGranted(key: string) {
if (!editState) return;
const next = new Set(editState.granted);
const nextDenied = new Set(editState.denied);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
nextDenied.delete(key);
}
setEditState({ ...editState, granted: next, denied: nextDenied });
}
function toggleDenied(key: string) {
if (!editState) return;
const next = new Set(editState.denied);
const nextGranted = new Set(editState.granted);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
nextGranted.delete(key);
}
setEditState({ ...editState, denied: next, granted: nextGranted });
}
async function handleSaveRole() {
if (!editState) return;
setActionError(null);
await updateRoleMutation.mutateAsync({ id: editState.userId, systemRole: editState.systemRole });
}
async function handleSavePermissions() {
if (!editState) return;
setActionError(null);
const granted = Array.from(editState.granted);
const denied = Array.from(editState.denied);
const chapterIds = editState.chapterIds
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const overrides: PermissionOverrides = {
...(granted.length > 0 ? { granted: granted as unknown as PermissionKey[] } : {}),
...(denied.length > 0 ? { denied: denied as unknown as PermissionKey[] } : {}),
...(chapterIds.length > 0 ? { chapterIds } : {}),
};
const hasOverrides = granted.length > 0 || denied.length > 0 || chapterIds.length > 0;
await setPermissionsMutation.mutateAsync({
userId: editState.userId,
overrides: hasOverrides ? overrides : null,
});
}
async function handleReset() {
if (!editState) return;
setActionError(null);
await resetPermissionsMutation.mutateAsync({ userId: editState.userId });
}
const allUsers = (users ?? []) as unknown as UserRow[];
// Client-side filtering
const filteredUsers = allUsers.filter((u) => {
if (search) {
const q = search.toLowerCase();
if (!(u.name ?? "").toLowerCase().includes(q) && !u.email.toLowerCase().includes(q)) return false;
}
if (roleFilter && u.systemRole !== roleFilter) return false;
return true;
});
const usersViewPrefs = useViewPrefs("users");
const { sorted, sortField, sortDir, toggle } = useTableSort(filteredUsers, {
initialField: usersViewPrefs.savedSort?.field ?? null,
initialDir: usersViewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
usersViewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
function handleSort(field: string) {
if (field === "systemRole") {
toggle("systemRole", (u) => ROLE_ORDER[u.systemRole] ?? 99);
} else {
toggle(field as keyof UserRow);
}
}
const selectedUser = editState ? allUsers.find((u) => u.id === editState.userId) : null;
const isPending =
updateRoleMutation.isPending ||
setPermissionsMutation.isPending ||
resetPermissionsMutation.isPending;
function clearAll() {
setSearch("");
setRoleFilter("");
}
const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(roleFilter ? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }] : []),
];
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">User Management</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage user roles and permission overrides
</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 mb-3">
<input
type="search"
placeholder="Search by name or email…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64 bg-white dark:bg-gray-900 dark:text-gray-100"
/>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value as SystemRole | "")}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 bg-white dark:bg-gray-900 dark:text-gray-100"
>
<option value="">All Roles</option>
{Object.values(SystemRole).map((role) => (
<option key={role} value={role}>{SYSTEM_ROLE_LABELS[role]}</option>
))}
</select>
</div>
{chips.length > 0 && (
<div className="mb-3">
<FilterChips chips={chips} onClearAll={clearAll} />
</div>
)}
{actionError && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{actionError}
<button
type="button"
onClick={() => setActionError(null)}
className="text-red-400 hover:text-red-600 text-lg leading-none"
>
&times;
</button>
</div>
)}
{/* User Table */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
<SortableColumnHeader label="Email" field="email" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
<SortableColumnHeader label="Role" field="systemRole" sortField={sortField} sortDir={sortDir} onSort={handleSort} align="center" tooltip="System role determines default access level. ADMIN: full access · MANAGER: manage resources/projects · CONTROLLER: read + export costs · USER: standard access · VIEWER: read-only. Individual permissions can be overridden via the edit dialog." tooltipWidth="w-80" />
<SortableColumnHeader label="Created" field="createdAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Account creation date." />
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={5} className="text-center py-8 text-gray-400">
Loading
</td>
</tr>
)}
{!isLoading && sorted.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-8 text-gray-400">
No users found.
</td>
</tr>
)}
{sorted.map((user) => (
<tr
key={user.id}
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"
>
<td className="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
{user.name ?? <span className="italic text-gray-400"></span>}
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{user.email}</td>
<td className="px-4 py-3 text-center">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
ROLE_BADGE_COLORS[user.systemRole as SystemRole] ?? ROLE_BADGE_COLORS[SystemRole.USER]
}`}
>
{SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole}
</span>
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
{new Date(user.createdAt).toLocaleDateString("en-GB")}
</td>
<td className="px-4 py-3 text-right">
<button
type="button"
onClick={() => openEdit(user)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Edit Modal */}
{editState && selectedUser && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-2xl mx-4 flex flex-col max-h-[90vh]">
{/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Edit User
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{selectedUser.name ?? selectedUser.email}
</p>
</div>
<button
type="button"
onClick={closeEdit}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
>
&times;
</button>
</div>
{/* Modal Body */}
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-6">
{/* System Role */}
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
System Role
</h3>
<div className="flex items-center gap-3">
<select
value={editState.systemRole}
onChange={(e) =>
setEditState({ ...editState, systemRole: e.target.value as SystemRole })
}
className="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"
>
{Object.values(SystemRole).map((role) => (
<option key={role} value={role}>
{SYSTEM_ROLE_LABELS[role]}
</option>
))}
</select>
<button
type="button"
onClick={handleSaveRole}
disabled={isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{updateRoleMutation.isPending ? "Saving…" : "Save Role"}
</button>
</div>
</section>
{/* Effective Permissions */}
{effectivePerms && (
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Effective Permissions
</h3>
<div className="flex flex-wrap gap-1.5">
{ALL_PERMISSION_KEYS.map((key) => {
const isActive = effectivePerms.effectivePermissions.includes(key);
return (
<span
key={key}
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
isActive
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400"
: "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600 line-through"
}`}
>
{PERMISSION_LABELS[key] ?? key}
</span>
);
})}
</div>
</section>
)}
{/* Permission Overrides */}
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Permission Overrides
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Additional Grants */}
<div>
<p className="text-xs font-medium text-green-700 dark:text-green-400 mb-2 uppercase tracking-wide">
Additional Grants
</p>
<div className="space-y-1.5">
{ALL_PERMISSION_KEYS.map((key) => (
<label
key={`grant-${key}`}
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
>
<input
type="checkbox"
checked={editState.granted.has(key)}
onChange={() => toggleGranted(key)}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
{PERMISSION_LABELS[key] ?? key}
</label>
))}
</div>
</div>
{/* Explicit Denials */}
<div>
<p className="text-xs font-medium text-red-700 dark:text-red-400 mb-2 uppercase tracking-wide">
Explicit Denials
</p>
<div className="space-y-1.5">
{ALL_PERMISSION_KEYS.map((key) => (
<label
key={`deny-${key}`}
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
>
<input
type="checkbox"
checked={editState.denied.has(key)}
onChange={() => toggleDenied(key)}
className="rounded border-gray-300 text-red-600 focus:ring-red-500"
/>
{PERMISSION_LABELS[key] ?? key}
</label>
))}
</div>
</div>
</div>
{/* Chapter Scope */}
<div className="mt-4">
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
Chapter Scope (comma-separated IDs, leave blank for all)
</label>
<input
type="text"
value={editState.chapterIds}
onChange={(e) => setEditState({ ...editState, chapterIds: e.target.value })}
placeholder="e.g. chapter-1, chapter-2"
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"
/>
</div>
</section>
</div>
{/* Modal Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button
type="button"
onClick={handleReset}
disabled={isPending}
className="px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-200 dark:border-red-700 hover:border-red-300 dark:hover:border-red-600 rounded-lg disabled:opacity-50"
>
{resetPermissionsMutation.isPending ? "Resetting…" : "Reset to Defaults"}
</button>
<div className="flex gap-3">
<button
type="button"
onClick={closeEdit}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
Close
</button>
<button
type="button"
onClick={handleSavePermissions}
disabled={isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{setPermissionsMutation.isPending ? "Saving…" : "Save Permissions"}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,243 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type CategoryRow = {
id: string;
code: string;
name: string;
description: string | null;
sortOrder: number;
isDefault: boolean;
isActive: boolean;
};
type EditingCategory = {
id?: string;
code: string;
name: string;
description: string;
sortOrder: number;
isDefault: boolean;
};
const emptyCategory: EditingCategory = {
code: "",
name: "",
description: "",
sortOrder: 0,
isDefault: false,
};
export function UtilizationCategoriesClient() {
const [editing, setEditing] = useState<EditingCategory | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: categories, isLoading } = trpc.utilizationCategory.list.useQuery();
const createMut = trpc.utilizationCategory.create.useMutation({
onSuccess: () => { void utils.utilizationCategory.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const updateMut = trpc.utilizationCategory.update.useMutation({
onSuccess: () => { void utils.utilizationCategory.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
function openCreate() {
const maxOrder = Math.max(0, ...(categories ?? []).map((c) => (c as unknown as CategoryRow).sortOrder));
setEditing({ ...emptyCategory, sortOrder: maxOrder + 1 });
setError(null);
}
function openEdit(c: CategoryRow) {
setEditing({
id: c.id,
code: c.code,
name: c.name,
description: c.description ?? "",
sortOrder: c.sortOrder,
isDefault: c.isDefault,
});
setError(null);
}
function handleSave() {
if (!editing) return;
setError(null);
if (editing.id) {
updateMut.mutate({
id: editing.id,
data: {
code: editing.code,
name: editing.name,
description: editing.description || undefined,
sortOrder: editing.sortOrder,
isDefault: editing.isDefault,
},
});
} else {
createMut.mutate({
code: editing.code,
name: editing.name,
description: editing.description || undefined,
sortOrder: editing.sortOrder,
isDefault: editing.isDefault,
});
}
}
const isPending = createMut.isPending || updateMut.isPending;
const rows = (categories ?? []) as unknown as CategoryRow[];
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Utilization Categories</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Categories assigned to projects for chargeability reporting
</p>
</div>
<button
type="button"
onClick={openCreate}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
+ Add Category
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{error}
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
</div>
)}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Code</th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Name</th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Description</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Default</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Order</th>
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr><td colSpan={6} className="text-center py-8 text-gray-400">Loading...</td></tr>
)}
{!isLoading && rows.length === 0 && (
<tr><td colSpan={6} className="text-center py-8 text-gray-400">No categories yet.</td></tr>
)}
{rows.map((c) => (
<tr key={c.id} className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-4 py-3 font-mono font-medium text-gray-900 dark:text-gray-100">{c.code}</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{c.name}</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs max-w-xs truncate">{c.description ?? "—"}</td>
<td className="px-4 py-3 text-center">
{c.isDefault && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400">Default</span>
)}
</td>
<td className="px-4 py-3 text-center text-gray-400">{c.sortOrder}</td>
<td className="px-4 py-3 text-right">
<button type="button" onClick={() => openEdit(c)} className="text-xs text-brand-600 hover:text-brand-800 font-medium">Edit</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Category" : "Add Category"}
</h2>
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code</label>
<input
type="text"
value={editing.code}
onChange={(e) => setEditing({ ...editing, code: e.target.value })}
placeholder="Chg"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
<input
type="number"
value={editing.sortOrder}
onChange={(e) => setEditing({ ...editing, sortOrder: parseInt(e.target.value) || 0 })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
type="text"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
placeholder="Chargeable"
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
<textarea
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
rows={2}
placeholder="Revenue-generating client project work"
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 resize-none"
/>
</div>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={editing.isDefault}
onChange={(e) => setEditing({ ...editing, isDefault: e.target.checked })}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Default category for new projects
</label>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditing(null)} 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={handleSave}
disabled={isPending || !editing.code || !editing.name}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,483 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { AllocationStatus } from "@planarchy/shared";
import type { AllocationWithDetails, RecurrencePattern } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { RecurrenceEditor } from "./RecurrenceEditor.js";
const ALLOCATION_STATUSES = Object.values(AllocationStatus);
type EntryKind = "demand" | "assignment";
interface AllocationModalProps {
allocation?: AllocationWithDetails | null;
onClose: () => void;
onSuccess: () => void;
}
function toDateInputValue(date: Date | string | null | undefined): string {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
return d.toISOString().split("T")[0] ?? "";
}
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
const isEditing = Boolean(allocation);
const initialEntryKind: EntryKind = allocation && !allocation.resourceId ? "demand" : "assignment";
const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind);
const isDemandEntry = entryKind === "demand";
const [resourceId, setResourceId] = useState(allocation?.resourceId ?? "");
const [projectId, setProjectId] = useState(allocation?.projectId ?? "");
const [roleId, setRoleId] = useState(allocation?.roleId ?? "");
const [roleFreeText, setRoleFreeText] = useState(allocation?.role ?? "");
const [headcount, setHeadcount] = useState(allocation?.headcount ?? 1);
const [startDate, setStartDate] = useState(toDateInputValue(allocation?.startDate));
const [endDate, setEndDate] = useState(toDateInputValue(allocation?.endDate));
const [hoursPerDay, setHoursPerDay] = useState<number>(allocation?.hoursPerDay ?? 8);
const [status, setStatus] = useState<AllocationStatus>(
allocation?.status ?? AllocationStatus.PROPOSED,
);
const existingMeta = allocation?.metadata as Record<string, unknown> | undefined;
const [isRecurring, setIsRecurring] = useState<boolean>(!!existingMeta?.recurrence);
const [recurrence, setRecurrence] = useState<RecurrencePattern | undefined>(
existingMeta?.recurrence as RecurrencePattern | undefined,
);
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const { data: projects } = trpc.project.list.useQuery(
{ limit: 500 },
{ staleTime: 60_000 },
);
const { data: rolesData } = trpc.role.list.useQuery(
{ isActive: true },
{ staleTime: 60_000 },
);
const utils = trpc.useUtils();
const invalidatePlanningViews = () => {
void utils.allocation.list.invalidate();
void (utils as { allocation: { listView: { invalidate: () => Promise<unknown> } } }).allocation.listView.invalidate();
void utils.allocation.listDemands.invalidate();
void utils.allocation.listAssignments.invalidate();
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createDemandMutation = (trpc.allocation.createDemandRequirement.useMutation as any)({
onSuccess: () => {
invalidatePlanningViews();
onSuccess();
},
onError: (err: { message: string }) => {
setServerError(err.message);
},
}) as {
isPending: boolean;
isError: boolean;
error?: { message: string };
mutate: (input: unknown) => void;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createAssignmentMutation = (trpc.allocation.createAssignment.useMutation as any)({
onSuccess: () => {
invalidatePlanningViews();
onSuccess();
},
onError: (err: { message: string }) => {
setServerError(err.message);
},
}) as {
isPending: boolean;
isError: boolean;
error?: { message: string };
mutate: (input: unknown) => void;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateMutation = (trpc.allocation.update.useMutation as any)({
onSuccess: () => {
invalidatePlanningViews();
onSuccess();
},
onError: (err: { message: string }) => {
setServerError(err.message);
},
}) as {
isPending: boolean;
isError: boolean;
error?: { message: string };
mutate: (input: unknown) => void;
};
const isPending =
createDemandMutation.isPending ||
createAssignmentMutation.isPending ||
updateMutation.isPending;
useEffect(() => {
setServerError(null);
}, [resourceId, projectId, roleId, roleFreeText, startDate, endDate, hoursPerDay, status, entryKind]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setServerError(null);
if (!projectId) {
setServerError("Please select a project.");
return;
}
if (!isDemandEntry && !resourceId) {
setServerError("Please select a resource.");
return;
}
if (!startDate || !endDate) {
setServerError("Please fill in start and end dates.");
return;
}
const start = new Date(startDate);
const end = new Date(endDate);
if (end < start) {
setServerError("End date must be on or after start date.");
return;
}
const baseMeta = (allocation?.metadata as Record<string, unknown> | undefined) ?? {};
const metadata: Record<string, unknown> = {
...baseMeta,
...(isRecurring && recurrence ? { recurrence } : { recurrence: undefined }),
};
if (!isRecurring) delete metadata.recurrence;
// Determine role string from roleId if set
const rolesList = rolesData ?? [];
const selectedRole = rolesList.find((r) => r.id === roleId);
const roleString = selectedRole ? selectedRole.name : (roleFreeText || undefined);
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
if (isEditing && allocation) {
updateMutation.mutate({
id: getPlanningEntryMutationId(allocation),
data: {
resourceId: isDemandEntry ? undefined : (resourceId || undefined),
projectId,
role: roleString,
roleId: roleId || undefined,
headcount: isDemandEntry ? headcount : 1,
startDate: start,
endDate: end,
hoursPerDay,
percentage,
status: status as AllocationStatus,
metadata,
},
});
} else if (isDemandEntry) {
createDemandMutation.mutate({
projectId,
role: roleString,
roleId: roleId || undefined,
headcount,
startDate: start,
endDate: end,
hoursPerDay,
percentage,
status: status as AllocationStatus,
metadata,
});
} else {
createAssignmentMutation.mutate({
resourceId,
projectId,
role: roleString,
roleId: roleId || undefined,
startDate: start,
endDate: end,
hoursPerDay,
percentage,
status: status as AllocationStatus,
metadata,
});
}
}
const inputClass =
"w-full 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 dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const resourceList = (resources?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
const projectList = (projects?.projects ?? []) as Array<{ id: string; name: string; shortCode: string }>;
const rolesList = (rolesData ?? []) as Array<{ id: string; name: string; color: string | null }>;
const entryLabel = isDemandEntry ? "Open Demand" : "Assignment";
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-xl mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{/* Header */}
<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">
{isEditing ? `Edit ${entryLabel}` : `New ${entryLabel}`}
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors text-xl leading-none"
aria-label="Close"
>
&times;
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
{/* Demand toggle */}
<div className="flex items-center gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<label className="flex items-center gap-2 text-sm cursor-pointer select-none flex-1">
<input
type="checkbox"
checked={isDemandEntry}
disabled={isEditing}
onChange={(e) => {
setEntryKind(e.target.checked ? "demand" : "assignment");
if (e.target.checked) setResourceId("");
}}
className="rounded border-gray-300 dark:border-gray-600 disabled:cursor-not-allowed disabled:opacity-60"
/>
<div>
<span className="font-medium text-gray-800 dark:text-gray-100">Open demand</span>
<span className="block text-xs text-gray-500 dark:text-gray-400">
{isEditing
? "Demand vs assignment type is fixed after creation during the compatibility migration."
: "No resource assigned yet and tracked as staffing demand"}
</span>
</div>
</label>
{isDemandEntry && (
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Headcount:</label>
<input
type="number"
value={headcount}
onChange={(e) => setHeadcount(Math.max(1, Number(e.target.value)))}
min={1}
max={50}
className="w-16 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm text-center dark:bg-gray-900 dark:text-gray-100"
/>
</div>
)}
</div>
{/* Resource is only required for assignments */}
{!isDemandEntry && (
<div>
<label htmlFor="modal-resource" className={labelClass}>
Resource <span className="text-red-500">*</span>
</label>
<select
id="modal-resource"
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
className={inputClass}
required={!isDemandEntry}
>
<option value="">Select a resource</option>
{resourceList.map((r) => (
<option key={r.id} value={r.id}>
{r.displayName} ({r.eid})
</option>
))}
</select>
</div>
)}
{/* Project */}
<div>
<label htmlFor="modal-project" className={labelClass}>
Project <span className="text-red-500">*</span>
</label>
<select
id="modal-project"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className={inputClass}
required
>
<option value="">Select a project</option>
{projectList.map((p) => (
<option key={p.id} value={p.id}>
{p.shortCode} {p.name}
</option>
))}
</select>
</div>
{/* Role */}
<div>
<label htmlFor="modal-role" className={labelClass}>Role</label>
<select
id="modal-role"
value={roleId}
onChange={(e) => setRoleId(e.target.value)}
className={inputClass}
>
<option value="">No role / custom</option>
{rolesList.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
{!roleId && (
<input
type="text"
value={roleFreeText}
onChange={(e) => setRoleFreeText(e.target.value)}
placeholder="Or type a custom role…"
className={`${inputClass} mt-1`}
maxLength={200}
/>
)}
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="modal-start" className={labelClass}>
Start Date <span className="text-red-500">*</span>
</label>
<DateInput
id="modal-start"
value={startDate}
onChange={setStartDate}
className={inputClass}
required
/>
</div>
<div>
<label htmlFor="modal-end" className={labelClass}>
End Date <span className="text-red-500">*</span>
</label>
<DateInput
id="modal-end"
value={endDate}
onChange={setEndDate}
min={startDate}
className={inputClass}
required
/>
</div>
</div>
{/* Hours/Day + Status */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="modal-hours" className={labelClass}>
Hours / Day
</label>
<input
id="modal-hours"
type="number"
value={hoursPerDay}
onChange={(e) => setHoursPerDay(Number(e.target.value))}
min={0.5}
max={8}
step={0.5}
className={inputClass}
/>
</div>
<div>
<label htmlFor="modal-status" className={labelClass}>
Status
</label>
<select
id="modal-status"
value={status}
onChange={(e) => setStatus(e.target.value as AllocationStatus)}
className={inputClass}
>
{ALLOCATION_STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
{/* Recurring toggle */}
<div>
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<input
type="checkbox"
checked={isRecurring}
onChange={(e) => {
setIsRecurring(e.target.checked);
if (!e.target.checked) setRecurrence(undefined);
}}
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="font-medium text-gray-700 dark:text-gray-300">Recurring schedule</span>
</label>
{isRecurring && (
<div className="mt-2">
<RecurrenceEditor value={recurrence} onChange={setRecurrence} />
</div>
)}
</div>
{/* Server error */}
{serverError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{serverError}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving…" : "Save"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,606 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { formatDate } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
import { AllocationModal } from "./AllocationModal.js";
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, ColumnDef } from "@planarchy/shared";
import { AllocationStatus, ALLOCATION_COLUMNS } from "@planarchy/shared";
import { useSelection } from "~/hooks/useSelection.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { FilterBar } from "~/components/ui/FilterBar.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
import { ColumnTogglePanel } from "~/components/ui/ColumnTogglePanel.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
const STATUS_BADGE: Record<string, string> = {
ACTIVE: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
PROPOSED: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
CONFIRMED: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
COMPLETED: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400",
CANCELLED: "bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400",
};
const ALL_ALLOC_STATUSES = [
{ value: "PROPOSED", label: "Proposed" },
{ value: "CONFIRMED", label: "Confirmed" },
{ value: "ACTIVE", label: "Active" },
{ value: "COMPLETED", label: "Completed" },
{ value: "CANCELLED", label: "Cancelled" },
] as const;
type AllocationAssignmentsView = AllocationReadModel<AllocationLike>;
type DemandRow = AllocationWithDetails & {
sourceAllocationId?: string;
requestedHeadcount?: number;
unfilledHeadcount?: number;
};
export function AllocationsClient() {
const [modalOpen, setModalOpen] = useState(false);
const [editingAllocation, setEditingAllocation] = useState<AllocationWithDetails | null>(null);
const [filterProjectId, setFilterProjectId] = useState<string>("");
const [filterResourceId, setFilterResourceId] = useState<string>("");
const [filterStatus, setFilterStatus] = useState<string>("");
const [hidePastProjects, setHidePastProjects] = useState(true);
const [hideCompletedProjects, setHideCompletedProjects] = useState(true);
const [hideDraftProjects, setHideDraftProjects] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<{ single?: AllocationWithDetails; ids?: string[] } | null>(null);
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
const selection = useSelection();
const utils = trpc.useUtils();
const { canViewCosts } = usePermissions();
// ─── Column visibility ────────────────────────────────────────────────────
const baseColumns = useMemo<ColumnDef[]>(
() => (canViewCosts ? ALLOCATION_COLUMNS : ALLOCATION_COLUMNS.filter((c) => c.key !== "cost")),
[canViewCosts],
);
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig("allocations", baseColumns);
const defaultKeys = useMemo(() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key), [baseColumns]);
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
{
projectId: filterProjectId || undefined,
resourceId: filterResourceId || undefined,
status: (filterStatus as AllocationStatus) || undefined,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ placeholderData: (prev: any) => prev, staleTime: 15_000 },
) as { data: AllocationAssignmentsView | undefined; isLoading: boolean };
const deleteDemandMutation = trpc.allocation.deleteDemandRequirement.useMutation({
onSuccess: async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
},
});
const deleteAssignmentMutation = trpc.allocation.deleteAssignment.useMutation({
onSuccess: async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
},
});
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
onSuccess: async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
selection.clear();
},
});
const batchStatusMutation = trpc.allocation.batchUpdateStatus.useMutation({
onSuccess: async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
selection.clear();
},
});
useEffect(() => {
selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterProjectId, filterResourceId, filterStatus, hidePastProjects, hideCompletedProjects, hideDraftProjects]);
function openCreate() {
setEditingAllocation(null);
setModalOpen(true);
}
function openEdit(alloc: AllocationWithDetails) {
setEditingAllocation(alloc);
setModalOpen(true);
}
function closeModal() {
setModalOpen(false);
setEditingAllocation(null);
}
const assignmentList = (allocationView?.assignments ?? []) as unknown as AllocationWithDetails[];
const demandList = (allocationView?.demands ?? []) as unknown as DemandRow[];
const today = new Date();
today.setHours(0, 0, 0, 0);
const filteredAllocations = assignmentList.filter((alloc) => {
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today) return false;
if (hideCompletedProjects && alloc.project?.status && ["COMPLETED", "CANCELLED"].includes(alloc.project.status)) return false;
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
return true;
});
const filteredDemands = demandList.filter((alloc) => {
if (filterResourceId) return false;
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today) return false;
if (hideCompletedProjects && alloc.project?.status && ["COMPLETED", "CANCELLED"].includes(alloc.project.status)) return false;
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
return true;
});
const allocViewPrefs = useViewPrefs("allocations");
const { sorted, sortField, sortDir, toggle } = useTableSort(filteredAllocations, {
initialField: allocViewPrefs.savedSort?.field ?? null,
initialDir: allocViewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
allocViewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
const allocationIds = sorted.map((a) => a.id);
const allocationMutationIdsByDisplayId = useMemo(
() =>
new Map(
sorted.map((allocation) => [allocation.id, getPlanningEntryMutationId(allocation)]),
),
[sorted],
);
const selectedMutationIds = useMemo(
() =>
selection.selectedArray.flatMap((displayId) => {
const mutationId = allocationMutationIdsByDisplayId.get(displayId);
return mutationId ? [mutationId] : [];
}),
[allocationMutationIdsByDisplayId, selection.selectedArray],
);
function handleSort(field: string) {
if (field === "resource") {
toggle("resource", (a) => a.resource?.displayName ?? null);
} else if (field === "project") {
toggle("project", (a) => a.project?.name ?? null);
} else {
toggle(field);
}
}
function clearAll() {
setFilterProjectId("");
setFilterResourceId("");
setFilterStatus("");
setHidePastProjects(false);
setHideCompletedProjects(false);
setHideDraftProjects(false);
}
const chips = [
...(filterProjectId ? [{ label: `Project filter active`, onRemove: () => setFilterProjectId("") }] : []),
...(filterResourceId ? [{ label: `Resource filter active`, onRemove: () => setFilterResourceId("") }] : []),
...(filterStatus ? [{ label: `Status: ${filterStatus}`, onRemove: () => setFilterStatus("") }] : []),
...(hidePastProjects ? [{ label: "Hiding past projects", onRemove: () => setHidePastProjects(false) }] : []),
...(hideCompletedProjects ? [{ label: "Hiding completed/cancelled", onRemove: () => setHideCompletedProjects(false) }] : []),
...(hideDraftProjects ? [{ label: "Hiding draft projects", onRemove: () => setHideDraftProjects(false) }] : []),
];
function formatPeriod(alloc: AllocationWithDetails) {
return formatDate(alloc.startDate) + " \u2192 " + formatDate(alloc.endDate);
}
function handleSingleDelete(allocation: AllocationWithDetails) {
const id = getPlanningEntryMutationId(allocation);
if (!allocation.resourceId) {
deleteDemandMutation.mutate({ id });
return;
}
deleteAssignmentMutation.mutate({ id });
}
const singleDeletePending = deleteDemandMutation.isPending || deleteAssignmentMutation.isPending;
return (
<div className="p-6 pb-24">
{/* Page header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Allocations</h1>
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
{isLoading
? "Loading…"
: `${filteredAllocations.length} assignment${filteredAllocations.length !== 1 ? "s" : ""}${filteredDemands.length > 0 ? ` · ${filteredDemands.length} open demand${filteredDemands.length !== 1 ? "s" : ""}` : ""}`}
</p>
</div>
<div className="flex items-center gap-2">
<a
href="/api/reports/allocations"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors flex items-center gap-2"
>
PDF
</a>
<a
href="/api/reports/allocations?format=xlsx"
download
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors flex items-center gap-2"
>
XLS
</a>
<button
type="button"
onClick={openCreate}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
New Planning Entry
</button>
</div>
</div>
{/* Filters */}
<FilterBar>
<ProjectCombobox
value={filterProjectId || null}
onChange={(id) => setFilterProjectId(id ?? "")}
placeholder="Filter by project…"
className="min-w-[280px]"
/>
<ResourceCombobox
value={filterResourceId || null}
onChange={(id) => setFilterResourceId(id ?? "")}
placeholder="Filter by resource…"
className="min-w-[180px]"
/>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="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-900 dark:text-gray-100"
>
<option value="">All Statuses</option>
{ALL_ALLOC_STATUSES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
<input
type="checkbox"
checked={hidePastProjects}
onChange={(e) => setHidePastProjects(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
Hide past
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
<input
type="checkbox"
checked={hideCompletedProjects}
onChange={(e) => setHideCompletedProjects(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
Hide completed
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
<input
type="checkbox"
checked={hideDraftProjects}
onChange={(e) => setHideDraftProjects(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
Hide drafts
</label>
<ColumnTogglePanel
allColumns={allColumns}
visibleKeys={visibleKeys}
onSetVisible={setVisible}
defaultKeys={defaultKeys}
/>
</FilterBar>
{/* Filter chips */}
{chips.length > 0 && (
<div className="mb-3">
<FilterChips chips={chips} onClearAll={clearAll} />
</div>
)}
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 w-10">
<input
type="checkbox"
checked={selection.isAllSelected(allocationIds)}
ref={(el) => {
if (el) el.indeterminate = selection.isIndeterminate(allocationIds);
}}
onChange={() => selection.toggleAll(allocationIds)}
className="rounded border-gray-300 dark:border-gray-600"
/>
</th>
{visibleColumns.map((col) => {
const tooltips: Record<string, { tip: string; width?: string }> = {
role: { tip: "The role this allocation was created for. May differ from the resource's primary role." },
hoursPerDay: { tip: "Planned working hours per calendar day for this allocation." },
cost: { tip: "Resource LCR × hours per day. Reflects the cost of one day of work for this allocation." },
status: { tip: "PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed.", width: "w-72" },
};
const t = tooltips[col.key];
const fieldMap: Record<string, string> = { dates: "startDate", hoursPerDay: "hoursPerDay", cost: "dailyCostCents" };
return (
<SortableColumnHeader
key={col.key}
label={col.label}
field={fieldMap[col.key] ?? col.key}
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
{...(t?.tip ? { tooltip: t.tip } : {})}
{...(t?.width ? { tooltipWidth: t.width } : {})}
/>
);
})}
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{isLoading && (
<tr>
<td colSpan={9} className="text-center py-12 text-gray-400 dark:text-gray-500 text-sm">Loading allocations</td>
</tr>
)}
{!isLoading && sorted.length === 0 && (
<tr>
<td colSpan={9} className="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No assignments found.</td>
</tr>
)}
{!isLoading &&
sorted.map((alloc) => {
const isSelected = selection.selectedIds.has(alloc.id);
return (
<tr key={alloc.id} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}>
<td className="px-4 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => selection.toggle(alloc.id)}
className="rounded border-gray-300 dark:border-gray-600"
/>
</td>
{visibleColumns.map((col) => {
switch (col.key) {
case "resource":
return <td key={col.key} className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">{alloc.resource?.displayName ?? "—"}</td>;
case "project":
return (
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{alloc.project ? (
<><span className="font-mono text-xs">{alloc.project.shortCode}</span> {alloc.project.name}</>
) : "—"}
</td>
);
case "role":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{alloc.role}</td>;
case "dates":
return <td key={col.key} className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatPeriod(alloc)}</td>;
case "hoursPerDay":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alloc.hoursPerDay}h</td>;
case "cost":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{(alloc.dailyCostCents / 100).toFixed(0)} </td>;
case "status":
return (
<td key={col.key} className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[alloc.status] ?? "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"}`}>
{alloc.status}
</span>
</td>
);
default:
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500"></td>;
}
})}
<td className="px-4 py-3">
<div className="flex items-center gap-2 justify-end">
<button type="button" onClick={() => openEdit(alloc)} className="text-xs text-blue-600 hover:text-blue-800 font-medium hover:underline">Edit</button>
<button
type="button"
onClick={() => setConfirmDelete({ single: alloc })}
disabled={singleDeletePending}
className="text-xs text-red-500 hover:text-red-700 font-medium hover:underline disabled:opacity-50"
>
Delete
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{!isLoading && filteredDemands.length > 0 && (
<div className="mt-6 bg-white dark:bg-gray-800 rounded-xl border border-amber-200 dark:border-amber-800/60 overflow-hidden">
<div className="px-4 py-3 border-b border-amber-200 dark:border-amber-800/60 bg-amber-50/70 dark:bg-amber-950/20 flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-200">Open Demands</h2>
<p className="text-xs text-amber-700 dark:text-amber-300/80">
Placeholder demand rows not yet assigned to a resource.
</p>
</div>
<span className="text-xs font-medium text-amber-700 dark:text-amber-300">
{filteredDemands.length} item{filteredDemands.length !== 1 ? "s" : ""}
</span>
</div>
<div className="divide-y divide-amber-100 dark:divide-amber-900/40">
{filteredDemands.map((demand) => (
<div
key={demand.id}
className="px-4 py-3 flex items-center justify-between gap-4 hover:bg-amber-50/40 dark:hover:bg-amber-950/10"
>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{demand.project ? (
<><span className="font-mono text-xs">{demand.project.shortCode}</span> {demand.project.name}</>
) : "Unknown project"}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{(demand.role ?? "Placeholder role")} · {formatPeriod(demand)} · {demand.hoursPerDay}h/day
</div>
</div>
<div className="flex items-center gap-4 flex-shrink-0">
<div className="text-right">
<div className="text-xs uppercase tracking-wide text-amber-700 dark:text-amber-300">Unfilled</div>
<div className="text-sm font-semibold text-amber-900 dark:text-amber-200">
{demand.unfilledHeadcount ?? demand.headcount} / {demand.requestedHeadcount ?? demand.headcount}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => openEdit(demand as AllocationWithDetails)}
className="text-xs text-blue-600 hover:text-blue-800 font-medium hover:underline"
>
Edit
</button>
<button
type="button"
onClick={() => setConfirmDelete({ single: demand as AllocationWithDetails })}
disabled={singleDeletePending}
className="text-xs text-red-500 hover:text-red-700 font-medium hover:underline disabled:opacity-50"
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Batch Status Picker */}
{batchStatusPicker && (
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl p-5 min-w-[220px]" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Set status for {selection.count} allocations</h3>
<div className="flex flex-col gap-1">
{ALL_ALLOC_STATUSES.map((s) => (
<button
key={s.value}
type="button"
onClick={() => {
setConfirmBatchStatus({ ids: selectedMutationIds, status: s.value });
setBatchStatusPicker(false);
}}
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[s.value]}`}>
{s.label}
</span>
</button>
))}
</div>
</div>
</div>
)}
{/* Confirm single delete */}
{confirmDelete?.single && (
<ConfirmDialog
title="Delete Allocation"
message={`Delete allocation for ${confirmDelete.single.resource?.displayName ?? "resource"} on ${confirmDelete.single.project?.name ?? "project"}?`}
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
handleSingleDelete(confirmDelete.single!);
setConfirmDelete(null);
}}
onCancel={() => setConfirmDelete(null)}
/>
)}
{/* Confirm batch delete */}
{confirmDelete?.ids && (
<ConfirmDialog
title="Delete Allocations"
message={`Delete ${confirmDelete.ids.length} selected allocation${confirmDelete.ids.length !== 1 ? "s" : ""}? This cannot be undone.`}
confirmLabel="Delete All"
variant="danger"
onConfirm={() => {
batchDeleteMutation.mutate({ ids: confirmDelete.ids! });
setConfirmDelete(null);
}}
onCancel={() => setConfirmDelete(null)}
/>
)}
{/* Confirm batch status */}
{confirmBatchStatus && (
<ConfirmDialog
title="Update Allocation Status"
message={`Set ${confirmBatchStatus.ids.length} allocation${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`}
confirmLabel="Update"
onConfirm={() => {
batchStatusMutation.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never });
setConfirmBatchStatus(null);
}}
onCancel={() => setConfirmBatchStatus(null)}
/>
)}
{/* Batch Action Bar */}
<BatchActionBar
count={selection.count}
onClear={selection.clear}
actions={[
{
label: "Set Status…",
onClick: () => setBatchStatusPicker(true),
disabled: batchStatusMutation.isPending,
},
{
label: `Delete (${selection.count})`,
variant: "danger",
onClick: () => setConfirmDelete({ ids: selectedMutationIds }),
disabled: batchDeleteMutation.isPending,
},
]}
/>
{/* Modal */}
{modalOpen && (
<AllocationModal allocation={editingAllocation} onClose={closeModal} onSuccess={closeModal} />
)}
</div>
);
}
@@ -0,0 +1,186 @@
"use client";
import { useRef, useState } from "react";
import { AllocationStatus } from "@planarchy/shared";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { trpc } from "~/lib/trpc/client.js";
interface OpenDemandAllocation {
id: string;
entityId?: string | null;
sourceAllocationId?: string | null;
projectId: string;
roleId: string | null;
role: string | null;
headcount: number;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
roleEntity?: { id: string; name: string; color: string | null } | null;
project?: { id: string; name: string; shortCode: string };
}
interface FillOpenDemandModalProps {
allocation: OpenDemandAllocation;
onClose: () => void;
onSuccess: () => void;
}
function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-GB", { year: "numeric", month: "short", day: "numeric" });
}
export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpenDemandModalProps) {
const [resourceId, setResourceId] = useState("");
const [hoursPerDay, setHoursPerDay] = useState<number>(allocation.hoursPerDay);
const [search, setSearch] = useState("");
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils();
const invalidatePlanningViews = async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
await utils.timeline.getEntries.invalidate();
await utils.timeline.getEntriesView.invalidate();
await utils.timeline.getProjectContext.invalidate();
await utils.timeline.getBudgetStatus.invalidate();
};
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, search: search || undefined, limit: 100 },
{ staleTime: 15_000 },
) as { data: { resources: Array<{ id: string; displayName: string; eid: string }> } | undefined };
const fillOpenDemandMutation = trpc.allocation.fillOpenDemandByAllocation.useMutation({
onSuccess: async () => {
await invalidatePlanningViews();
onSuccess();
},
onError: (err) => setServerError(err.message),
});
const roleName = allocation.roleEntity?.name ?? allocation.role ?? "Unknown Role";
const roleColor = allocation.roleEntity?.color ?? "#6366f1";
const resourceList = resources?.resources ?? [];
const isPending = fillOpenDemandMutation.isPending;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!resourceId) {
setServerError("Please select a resource.");
return;
}
fillOpenDemandMutation.mutate({
allocationId: getPlanningEntryMutationId(allocation),
resourceId,
hoursPerDay,
status: AllocationStatus.PROPOSED,
});
}
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-md mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
<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">Assign Open Demand</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none">&times;</button>
</div>
<div className="px-6 pt-4 pb-2">
{/* Demand summary */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 mb-4 flex items-start gap-3">
<div className="w-3 h-3 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: roleColor }} />
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{allocation.project?.name} · {formatDate(allocation.startDate)} {formatDate(allocation.endDate)}
</div>
{allocation.headcount > 1 && (
<div className="text-xs text-amber-600 mt-0.5">
{allocation.headcount} slots remaining assigning one resource
</div>
)}
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="px-6 pb-5 space-y-4">
{/* Resource search */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Search Resource
</label>
<input
type="text"
placeholder="Search by name or EID…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full 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 dark:bg-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Assign Resource <span className="text-red-500">*</span>
</label>
<select
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
className="w-full 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 dark:bg-gray-900 dark:text-gray-100"
required
size={Math.min(6, Math.max(3, resourceList.length))}
>
<option value="">Select a resource</option>
{resourceList.map((r) => (
<option key={r.id} value={r.id}>
{r.displayName} ({r.eid})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hours / Day</label>
<input
type="number"
value={hoursPerDay}
onChange={(e) => setHoursPerDay(Number(e.target.value))}
min={0.5}
max={8}
step={0.5}
className="w-full 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 dark:bg-gray-900 dark:text-gray-100"
/>
</div>
{serverError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{serverError}
</div>
)}
<div className="flex items-center justify-end gap-3 pt-2">
<button type="button" onClick={onClose} disabled={isPending} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50">
Cancel
</button>
<button type="submit" disabled={isPending || !resourceId} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50">
{isPending ? "Assigning…" : "Create Assignment"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,202 @@
"use client";
import { RecurrenceFrequency } from "@planarchy/shared";
import type { RecurrencePattern } from "@planarchy/shared";
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
interface RecurrenceEditorProps {
value: RecurrencePattern | undefined;
onChange: (pattern: RecurrencePattern | undefined) => void;
}
export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
const freq = value?.frequency ?? RecurrenceFrequency.WEEKLY;
function update(patch: Partial<RecurrencePattern>) {
onChange({ ...value, frequency: freq, ...patch });
}
function setFrequency(f: RecurrenceFrequency) {
// Reset pattern-specific fields when switching frequency
onChange({ frequency: f });
}
function toggleWeekday(dow: number) {
const current = value?.weekdays ?? [];
const next = current.includes(dow)
? current.filter((d) => d !== dow)
: [...current, dow].sort((a, b) => a - b);
update({ weekdays: next });
}
const inputClass =
"px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100";
const labelClass = "text-xs font-medium text-gray-600 dark:text-gray-400 block mb-1";
return (
<div className="space-y-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
{/* Frequency selector */}
<div>
<span className={labelClass}>Frequency</span>
<div className="flex gap-2 flex-wrap">
{Object.values(RecurrenceFrequency).map((f) => (
<button
key={f}
type="button"
onClick={() => setFrequency(f)}
className={`px-3 py-1 text-xs rounded-full border transition-colors ${
freq === f
? "bg-brand-600 text-white border-brand-600"
: "border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-brand-400"
}`}
>
{f === RecurrenceFrequency.WEEKLY
? "Weekly"
: f === RecurrenceFrequency.BIWEEKLY
? "Biweekly"
: f === RecurrenceFrequency.MONTHLY
? "Monthly"
: "Custom"}
</button>
))}
</div>
</div>
{/* Weekday picker — WEEKLY and BIWEEKLY */}
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
<div>
<span className={labelClass}>Days of week</span>
<div className="flex gap-1">
{WEEKDAY_LABELS.map((label, dow) => {
const selected = (value?.weekdays ?? []).includes(dow);
return (
<button
key={dow}
type="button"
onClick={() => toggleWeekday(dow)}
className={`w-9 h-9 text-xs rounded-full border font-medium transition-colors ${
selected
? "bg-brand-600 text-white border-brand-600"
: "border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-brand-400"
}`}
>
{label}
</button>
);
})}
</div>
</div>
)}
{/* Biweekly interval */}
{freq === RecurrenceFrequency.BIWEEKLY && (
<div>
<label className={labelClass}>Every N weeks</label>
<input
type="number"
min={2}
max={8}
value={value?.interval ?? 2}
onChange={(e) => update({ interval: Number(e.target.value) })}
className={`${inputClass} w-24`}
/>
</div>
)}
{/* Monthly — day of month */}
{freq === RecurrenceFrequency.MONTHLY && (
<div>
<label className={labelClass}>Day of month (131)</label>
<input
type="number"
min={1}
max={31}
value={value?.monthDay ?? 1}
onChange={(e) => update({ monthDay: Number(e.target.value) })}
className={`${inputClass} w-24`}
/>
</div>
)}
{/* Custom — hoursPerDay override */}
{freq === RecurrenceFrequency.CUSTOM && (
<div>
<label className={labelClass}>Hours per active day</label>
<input
type="number"
min={0.5}
max={24}
step={0.5}
value={value?.hoursPerDay ?? 8}
onChange={(e) => update({ hoursPerDay: Number(e.target.value) })}
className={`${inputClass} w-24`}
/>
</div>
)}
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
{freq !== RecurrenceFrequency.CUSTOM && (
<div>
<label className={labelClass}>Hours per recurring day (optional override)</label>
<input
type="number"
min={0.5}
max={24}
step={0.5}
placeholder="Use allocation default"
value={value?.hoursPerDay ?? ""}
onChange={(e) => {
const next = { ...value, frequency: freq } as RecurrencePattern;
if (e.target.value === "") {
delete next.hoursPerDay;
} else {
next.hoursPerDay = Number(e.target.value);
}
onChange(next);
}}
className={`${inputClass} w-40`}
/>
</div>
)}
{/* Optional date range overrides */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>Recurrence start (optional)</label>
<input
type="date"
value={value?.startDate ?? ""}
onChange={(e) => {
const next = { ...value, frequency: freq } as RecurrencePattern;
if (e.target.value) {
next.startDate = e.target.value;
} else {
delete next.startDate;
}
onChange(next);
}}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Recurrence end (optional)</label>
<input
type="date"
value={value?.endDate ?? ""}
onChange={(e) => {
const next = { ...value, frequency: freq } as RecurrencePattern;
if (e.target.value) {
next.endDate = e.target.value;
} else {
delete next.endDate;
}
onChange(next);
}}
className={inputClass}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,515 @@
"use client";
import { useState, useId } from "react";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import { trpc } from "~/lib/trpc/client.js";
import * as XLSX from "xlsx";
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
// SVG fill colors for the bar chart (work in both light and dark contexts)
const PROFICIENCY_SVG_COLORS = ["#9ca3af", "#60a5fa", "#818cf8", "#f59e0b", "#4ade80"];
// Tailwind class sets per proficiency level (15), dark-mode aware
const PROFICIENCY_CLASSES = [
"bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500",
"bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/60 dark:text-blue-200 dark:border-blue-600",
"bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-900/60 dark:text-indigo-200 dark:border-indigo-500",
"bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/60 dark:text-amber-200 dark:border-amber-500",
"bg-green-100 text-green-800 border-green-300 dark:bg-green-900/60 dark:text-green-200 dark:border-green-500",
];
function proficiencyClasses(level: number): string {
const idx = Math.max(0, Math.min(4, Math.round(level) - 1));
return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!;
}
function ProficiencyBadge({ value }: { value: number }) {
return (
<span className={`inline-block px-2 py-0.5 text-xs rounded font-medium border ${proficiencyClasses(value)}`}>
{value} {PROFICIENCY_LABELS[value] ?? ""}
</span>
);
}
type SkillRule = { skill: string; minProficiency: number };
export function SkillsAnalytics() {
const datalistId = useId();
// ── Skill table filters ──────────────────────────────────────────────────
const [categoryFilter, setCategoryFilter] = useState<string>("");
const [minCount, setMinCount] = useState<number>(1);
const [skillSearch, setSkillSearch] = useState<string>("");
// ── People Finder ────────────────────────────────────────────────────────
const [rules, setRules] = useState<SkillRule[]>([]);
const [operator, setOperator] = useState<"AND" | "OR">("AND");
const [peopleChapter, setPeopleChapter] = useState<string>("");
const { data, isLoading, error } = trpc.resource.getSkillsAnalytics.useQuery(undefined, {
staleTime: 60_000,
});
const activeRules = rules.filter((r) => r.skill.trim().length > 0);
const { data: peopleResults, isFetching: peopleFetching } =
trpc.resource.searchBySkills.useQuery(
{
rules: activeRules,
operator,
...(peopleChapter ? { chapter: peopleChapter } : {}),
},
{
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)));
}
function exportXlsx() {
if (!data) return;
const rows = data.aggregated.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");
XLSX.writeFile(wb, `skills-analytics-${Date.now()}.xlsx`);
}
const allSkillNames = (data?.aggregated ?? []).map((e) => e.skill);
const filtered = (data?.aggregated ?? []).filter((e) => {
if (categoryFilter && e.category !== categoryFilter) return false;
if (e.count < minCount) return false;
if (skillSearch && !e.skill.toLowerCase().includes(skillSearch.toLowerCase())) return false;
return true;
});
const { sorted: sortedSkills, sortField: skillSortField, sortDir: skillSortDir, toggle: skillToggle } = useTableSort(filtered);
const top20 = filtered.slice(0, 20);
const gapSkills = (data?.aggregated ?? []).filter((e) => e.count < 3 && e.avgProficiency >= 3);
if (isLoading) {
return (
<div className="p-6 animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-64" />
<div className="h-64 bg-gray-100 rounded-xl" />
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
{error.message}
</div>
</div>
);
}
return (
<div className="p-6 pb-24 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Skills Analytics</h1>
<p className="text-sm text-gray-500 mt-1">
{data?.totalResources} active resources · {data?.totalSkillEntries} distinct skills
</p>
</div>
<button
type="button"
onClick={exportXlsx}
disabled={!data}
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
Export XLS
</button>
</div>
{/* ── People Finder ──────────────────────────────────────────────────── */}
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-800">People Finder</h2>
<span className="text-xs text-gray-400">
Find resources that match skill criteria
</span>
</div>
{/* Rules */}
<datalist id={datalistId}>
{allSkillNames.map((s) => (
<option key={s} value={s} />
))}
</datalist>
<div className="space-y-2">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2 flex-wrap">
{/* AND / OR connector label */}
{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 bg-brand-50 text-brand-700 hover:bg-brand-100 transition-colors shrink-0"
>
{operator}
</button>
)}
{idx === 0 && (
<span className="w-12 text-center text-xs font-medium text-gray-400 shrink-0">
knows
</span>
)}
{/* Skill input */}
<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 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
{/* Min proficiency selector */}
<div className="flex items-center gap-1">
<span className="text-xs text-gray-400 shrink-0">min.</span>
<div className="flex rounded-lg overflow-hidden border border-gray-200">
{[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 text-gray-500 hover:bg-gray-50"
}`}
>
{lvl}
</button>
))}
</div>
</div>
{/* Remove */}
<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>
{/* Add rule + chapter filter 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 text-brand-600 hover:bg-brand-50 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">Match:</span>
<button
type="button"
onClick={() => setOperator("AND")}
className={`px-2.5 py-1 text-xs font-medium rounded-l-lg border transition-colors ${
operator === "AND"
? "bg-brand-600 border-brand-600 text-white"
: "bg-white border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
>
All (AND)
</button>
<button
type="button"
onClick={() => setOperator("OR")}
className={`px-2.5 py-1 text-xs font-medium rounded-r-lg border -ml-px transition-colors ${
operator === "OR"
? "bg-brand-600 border-brand-600 text-white"
: "bg-white border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
>
Any (OR)
</button>
</div>
)}
{(data?.allChapters ?? []).length > 0 && (
<div className="flex items-center gap-1.5 ml-auto">
<span className="text-xs text-gray-500">Chapter:</span>
<select
value={peopleChapter}
onChange={(e) => setPeopleChapter(e.target.value)}
className="px-2 py-1.5 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">All chapters</option>
{(data?.allChapters ?? []).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
)}
</div>
{/* Results */}
{activeRules.length > 0 && (
<div className="border-t border-gray-100 pt-4">
{peopleFetching ? (
<div className="text-sm text-gray-400 animate-pulse">Searching</div>
) : peopleResults && peopleResults.length === 0 ? (
<p className="text-sm text-gray-400 italic">No resources match these criteria.</p>
) : peopleResults && peopleResults.length > 0 ? (
<>
<p className="text-xs text-gray-500 mb-3">
{peopleResults.length} resource{peopleResults.length !== 1 ? "s" : ""} found
</p>
<div className="space-y-2">
{peopleResults.map((person) => (
<div
key={person.id}
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/resources/${person.id}`}
className="text-sm font-medium text-gray-900 hover:text-brand-600 transition-colors"
>
{person.displayName}
</a>
{person.chapter && (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-700 dark:bg-gray-600 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>
<a
href={`/resources/${person.id}`}
className="text-xs text-brand-600 hover:underline shrink-0 mt-0.5"
>
View
</a>
</div>
))}
</div>
</>
) : null}
</div>
)}
</div>
{/* ── Skill table filters ────────────────────────────────────────────── */}
<div className="flex flex-wrap gap-3 items-center">
{/* Fuzzy search */}
<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 skills…"
value={skillSearch}
onChange={(e) => setSkillSearch(e.target.value)}
className="pl-8 pr-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500 w-52"
/>
</div>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">All Categories</option>
{(data?.categories ?? []).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<label className="flex items-center gap-2 text-sm text-gray-600">
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 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</label>
<span className="text-sm text-gray-400">{filtered.length} skills shown</span>
{(skillSearch || categoryFilter || minCount > 1) && (
<button
type="button"
onClick={() => { setSkillSearch(""); setCategoryFilter(""); setMinCount(1); }}
className="text-xs text-brand-600 hover:underline"
>
Clear filters
</button>
)}
</div>
{/* Top 20 Skills Bar Chart */}
{top20.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-4">Top Skills by Resource Count</h2>
<ResponsiveContainer width="100%" height={320}>
<BarChart data={top20} layout="vertical" margin={{ left: 160, right: 20, top: 0, bottom: 0 }}>
<XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="skill" tick={{ fontSize: 11 }} width={155} />
<Tooltip
formatter={(value: number | undefined) => [`${value ?? 0} resources`, "Count"] as [string, string]}
contentStyle={{ fontSize: 12, borderRadius: 8 }}
/>
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
{top20.map((entry) => (
<Cell key={entry.skill} fill={PROFICIENCY_SVG_COLORS[Math.max(0, Math.min(4, Math.round(entry.avgProficiency) - 1))] ?? "#6b7280"} strokeWidth={0} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<p className="text-xs text-gray-400 mt-2">Bar color = average proficiency (light dark = low high)</p>
</div>
)}
{/* Skills Gap */}
{gapSkills.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3">
Skills Gap
<span className="ml-2 text-xs font-normal text-gray-400">high proficiency, few practitioners (&lt;3)</span>
</h2>
<div className="flex flex-wrap gap-2">
{gapSkills.map((e) => (
<button
key={e.skill}
type="button"
onClick={() => {
setRules((prev) => [...prev, { skill: e.skill, minProficiency: 3 }]);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
title="Add to People Finder"
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm rounded-full bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 transition-colors"
>
{e.skill}
<span className="text-xs opacity-70">{e.count} person{e.count !== 1 ? "s" : ""}</span>
</button>
))}
</div>
<p className="text-xs text-gray-400 mt-2">Click a skill to add it to the People Finder above.</p>
</div>
)}
{/* Skills Table */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<SortableColumnHeader label="Skill" field="skill" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} />
<SortableColumnHeader label="Category" field="category" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} />
<SortableColumnHeader label="Resources" field="count" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} align="right" />
<SortableColumnHeader label="Avg Prof." field="avgProficiency" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} align="right" />
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chapters</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Find</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sortedSkills.map((e) => (
<tr key={e.skill} className="hover:bg-gray-50">
<td className="px-4 py-2.5 font-medium text-gray-900">{e.skill}</td>
<td className="px-4 py-2.5 text-gray-500">{e.category}</td>
<td className="px-4 py-2.5 text-right text-gray-700">{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 text-xs">{e.chapters.join(", ") || "—"}</td>
<td className="px-4 py-2.5 text-center">
<button
type="button"
title="Add to People Finder"
onClick={() => {
setRules((prev) => [...prev, { skill: e.skill, minProficiency: 1 }]);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
className="text-gray-400 hover:text-brand-600 transition-colors"
>
<svg className="w-4 h-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</td>
</tr>
))}
{sortedSkills.length === 0 && (
<tr>
<td colSpan={6} className="text-center py-10 text-gray-400 text-sm">
No skills found matching the filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,502 @@
"use client";
import { useState } 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";
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" },
];
const INPUT_CLS =
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
const BTN_PRIMARY =
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
const BTN_SECONDARY =
"px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium";
const BTN_DANGER =
"px-2 py-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded text-sm transition-colors";
function makeEmptyField(order: number): BlueprintFieldDefinition {
return {
id: Math.random().toString(36).slice(2),
key: "",
label: "",
type: FieldType.TEXT,
required: false,
order,
};
}
// ---------------------------------------------------------------------------
// OptionsEditor — for SELECT / MULTI_SELECT
// ---------------------------------------------------------------------------
interface OptionsEditorProps {
options: FieldOption[];
onChange: (options: FieldOption[]) => void;
}
function OptionsEditor({ options, onChange }: OptionsEditorProps) {
function addOption() {
onChange([...options, { value: "", label: "" }]);
}
function removeOption(idx: number) {
onChange(options.filter((_, i) => i !== idx));
}
function updateOption(idx: number, field: "value" | "label", val: string) {
const next = options.map((o, i) =>
i === idx ? { ...o, [field]: val } : o,
);
onChange(next);
}
return (
<div className="mt-2 space-y-1.5">
<p className="text-xs font-medium text-gray-600">Options</p>
{options.map((opt, idx) => (
<div key={idx} className="flex items-center gap-2">
<input
type="text"
value={opt.value}
onChange={(e) => updateOption(idx, "value", e.target.value)}
placeholder="value"
className={`${INPUT_CLS} flex-1`}
/>
<input
type="text"
value={opt.label}
onChange={(e) => updateOption(idx, "label", e.target.value)}
placeholder="label"
className={`${INPUT_CLS} flex-1`}
/>
<button
type="button"
onClick={() => removeOption(idx)}
className={BTN_DANGER}
aria-label="Remove option"
>
×
</button>
</div>
))}
<button
type="button"
onClick={addOption}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
+ Add option
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// FieldRow — a single field definition row
// ---------------------------------------------------------------------------
interface FieldRowProps {
field: BlueprintFieldDefinition;
onChange: (field: BlueprintFieldDefinition) => void;
onDelete: () => void;
}
function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
const [expanded, setExpanded] = useState(false);
const needsOptions =
field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
function update<K extends keyof BlueprintFieldDefinition>(
key: K,
value: BlueprintFieldDefinition[K],
) {
onChange({ ...field, [key]: value });
}
return (
<div className="border border-gray-200 rounded-lg p-3 bg-white">
{/* Main row */}
<div className="flex flex-wrap items-center gap-2">
{/* Drag handle placeholder */}
<span className="text-gray-300 cursor-grab select-none text-lg leading-none">
</span>
{/* Key */}
<input
type="text"
value={field.key}
onChange={(e) => update("key", e.target.value)}
placeholder="field_key"
className={`${INPUT_CLS} w-36 font-mono`}
aria-label="Field key"
/>
{/* Label */}
<input
type="text"
value={field.label}
onChange={(e) => update("label", e.target.value)}
placeholder="Label"
className={`${INPUT_CLS} w-40`}
aria-label="Field label"
/>
{/* Type */}
<select
value={field.type}
onChange={(e) => {
const t = e.target.value as FieldType;
// Clear options when switching away from select types
const clearedOptions =
t === FieldType.SELECT || t === FieldType.MULTI_SELECT
? field.options ?? []
: undefined;
onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition);
}}
className={`${INPUT_CLS} w-36`}
aria-label="Field type"
>
{FIELD_TYPES.map((ft) => (
<option key={ft.value} value={ft.value}>
{ft.label}
</option>
))}
</select>
{/* Required */}
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none whitespace-nowrap">
<input
type="checkbox"
checked={field.required}
onChange={(e) => update("required", e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Req.
</label>
{/* Expand/Collapse optional fields */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="text-xs text-gray-400 hover:text-gray-600 ml-auto whitespace-nowrap"
aria-label={expanded ? "Collapse options" : "Expand options"}
>
{expanded ? "▲ less" : "▼ more"}
</button>
{/* Delete */}
<button
type="button"
onClick={onDelete}
className={BTN_DANGER}
aria-label="Delete field"
>
×
</button>
</div>
{/* Expanded optional fields */}
{expanded && (
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">Group</label>
<input
type="text"
value={field.group ?? ""}
onChange={(e) => update("group", e.target.value || undefined)}
placeholder="Section heading"
className={INPUT_CLS}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">
Placeholder
</label>
<input
type="text"
value={field.placeholder ?? ""}
onChange={(e) =>
update("placeholder", e.target.value || undefined)
}
placeholder="Placeholder text"
className={INPUT_CLS}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">
Description
</label>
<input
type="text"
value={field.description ?? ""}
onChange={(e) =>
update("description", e.target.value || undefined)
}
placeholder="Helper text"
className={INPUT_CLS}
/>
</div>
<div className="flex items-center gap-4 col-span-full pt-1">
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none">
<input
type="checkbox"
checked={field.showInList ?? false}
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 view
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none">
<input
type="checkbox"
checked={field.isFilterable ?? false}
onChange={(e) => update("isFilterable", e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Filterable
</label>
</div>
{needsOptions && (
<div className="col-span-full">
<OptionsEditor
options={field.options ?? []}
onChange={(opts) => update("options", opts)}
/>
</div>
)}
</div>
)}
{/* Options inline hint when collapsed */}
{!expanded && needsOptions && (field.options?.length ?? 0) === 0 && (
<p className="mt-1 text-xs text-amber-600">
No options defined yet click more to add them.
</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// BlueprintFieldEditor — the modal
// ---------------------------------------------------------------------------
interface BlueprintFieldEditorProps {
blueprintId: string;
blueprintName: string;
initialFieldDefs: BlueprintFieldDefinition[];
initialRolePresets?: StaffingRequirement[];
initialTab?: "fields" | "presets";
onClose: () => void;
}
export function BlueprintFieldEditor({
blueprintId,
blueprintName,
initialFieldDefs,
initialRolePresets = [],
initialTab = "fields",
onClose,
}: BlueprintFieldEditorProps) {
const utils = trpc.useUtils();
const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab);
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(
() =>
[...initialFieldDefs].sort((a, b) => a.order - b.order),
);
const [saveError, setSaveError] = useState<string | null>(null);
const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
const updateMutation = trpc.blueprint.update.useMutation();
const presetMutation = trpc.blueprint.updateRolePresets.useMutation();
function addField() {
setFields((prev) => [...prev, makeEmptyField(prev.length)]);
}
function removeField(idx: number) {
setFields((prev) =>
prev
.filter((_, i) => i !== idx)
.map((f, i) => ({ ...f, order: i })),
);
}
function updateField(idx: number, updated: BlueprintFieldDefinition) {
setFields((prev) =>
prev.map((f, i) => (i === idx ? updated : f)),
);
}
function handleSave() {
setSaveError(null);
// Reassign order by current list position
const normalised = fields.map((f, i) => ({ ...f, order: i }));
updateMutation.mutate(
{
id: blueprintId,
data: { fieldDefs: normalised },
},
{
onSuccess: async () => {
await utils.blueprint.list.invalidate();
onClose();
},
onError: (err) => {
setSaveError(err.message);
},
},
);
}
// Close on backdrop click
function handleBackdropClick(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === e.currentTarget) onClose();
}
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 rounded-xl shadow-2xl w-full max-w-3xl mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
Edit Fields:{" "}
<span className="text-gray-600 font-normal">{blueprintName}</span>
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
aria-label="Close"
>
×
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200 px-6">
{(["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"
}`}
>
{tab === "fields" ? "Fields" : "Role Presets"}
</button>
))}
</div>
{activeTab === "fields" ? (
<>
{/* Field list */}
<div className="px-6 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
{fields.length === 0 && (
<p className="text-sm text-gray-400 text-center py-8">
No fields yet. Click &ldquo;+ Add Field&rdquo; to get started.
</p>
)}
{fields.map((field, idx) => (
<FieldRow
key={field.id}
field={field}
onChange={(updated) => updateField(idx, updated)}
onDelete={() => removeField(idx)}
/>
))}
</div>
{/* Add field button */}
<div className="px-6 pb-2">
<button
type="button"
onClick={addField}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium"
>
<span className="text-lg leading-none">+</span> Add Field
</button>
</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">
{saveError}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
<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 className="px-6 py-4">
<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>
);
}
@@ -0,0 +1,510 @@
"use client";
import { useState, useEffect } from "react";
import type { FormEvent, MouseEvent } from "react";
import { BlueprintTarget } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { BlueprintFieldEditor } from "./BlueprintFieldEditor.js";
import { useSelection } from "~/hooks/useSelection.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { FilterBar } from "~/components/ui/FilterBar.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
const INPUT_CLS =
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
const BTN_PRIMARY =
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
const BTN_SECONDARY =
"px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium";
interface NewBlueprintModalProps {
onClose: () => void;
onCreated: () => void;
}
type BlueprintTargetValue = "RESOURCE" | "PROJECT";
type BlueprintSortField = "name" | "target" | "fieldCount" | "presetCount" | "global";
function NewBlueprintModal({ onClose, onCreated }: NewBlueprintModalProps) {
const utils = trpc.useUtils();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [target, setTarget] = useState<BlueprintTargetValue>("RESOURCE");
const [error, setError] = useState<string | null>(null);
const createMutation = trpc.blueprint.create.useMutation();
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("Name is required.");
return;
}
try {
await createMutation.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
target: target as BlueprintTarget,
fieldDefs: [],
defaults: {},
validationRules: [],
});
await utils.blueprint.list.invalidate();
onCreated();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create blueprint.");
}
}
function handleBackdropClick(e: MouseEvent<HTMLDivElement>) {
if (e.target === e.currentTarget) onClose();
}
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 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">
<h2 className="text-lg font-semibold text-gray-900">New Blueprint</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none" aria-label="Close">×</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Name <span className="text-red-500">*</span></label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Resource Extended Fields" className={INPUT_CLS} autoFocus />
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional description" className={`${INPUT_CLS} resize-none`} rows={2} />
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Target</label>
<select value={target} onChange={(e) => setTarget(e.target.value as BlueprintTargetValue)} className={INPUT_CLS}>
<option value="RESOURCE">Resource</option>
<option value="PROJECT">Project</option>
</select>
</div>
{error && <p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</p>}
<div className="flex items-center justify-end gap-3 pt-2">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>Cancel</button>
<button type="submit" disabled={createMutation.isPending} className={BTN_PRIMARY}>
{createMutation.isPending ? "Creating…" : "Create Blueprint"}
</button>
</div>
</form>
</div>
</div>
);
}
interface BlueprintRow {
id: string;
name: string;
description: string | null;
target: BlueprintTargetValue;
fieldDefs: unknown;
rolePresets: unknown;
isGlobal?: boolean;
}
interface BlueprintCardProps {
blueprint: BlueprintRow;
onEditFields: () => void;
onEditStaffing: () => void;
onToggleGlobal: () => void;
onDelete: () => void;
isSelected: boolean;
onToggleSelect: () => void;
}
function BlueprintCard({
blueprint,
onEditFields,
onEditStaffing,
onToggleGlobal,
onDelete,
isSelected,
onToggleSelect,
}: BlueprintCardProps) {
const fieldDefs = Array.isArray(blueprint.fieldDefs) ? (blueprint.fieldDefs as BlueprintFieldDefinition[]) : [];
const rolePresets = Array.isArray(blueprint.rolePresets) ? (blueprint.rolePresets as unknown[]) : [];
const fieldCount = fieldDefs.length;
const presetCount = rolePresets.length;
const isProject = blueprint.target === "PROJECT";
return (
<div className={`bg-white rounded-xl border p-5 flex flex-col gap-3 hover:shadow-sm transition-shadow ${isSelected ? "border-brand-400 bg-brand-50" : "border-gray-200"}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
<input
type="checkbox"
checked={isSelected}
onChange={onToggleSelect}
className="mt-0.5 rounded border-gray-300"
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{blueprint.name}</h3>
{blueprint.description && (
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{blueprint.description}</p>
)}
</div>
</div>
<span className={`shrink-0 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"}`}>
{blueprint.target}
</span>
</div>
<div className="flex flex-wrap gap-3 text-sm text-gray-500">
<span>{fieldCount === 0 ? "No fields" : `${fieldCount} field${fieldCount === 1 ? "" : "s"}`}</span>
{isProject && (
<span className={presetCount > 0 ? "text-brand-600 font-medium" : ""}>
{presetCount === 0 ? "No staffing presets" : `${presetCount} staffing preset${presetCount === 1 ? "" : "s"}`}
</span>
)}
{blueprint.isGlobal && (
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 font-medium">Global</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-gray-100">
<button type="button" onClick={onEditFields} className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors">
Edit Fields
</button>
{isProject && (
<button type="button" onClick={onEditStaffing} className="px-3 py-1.5 border border-brand-300 text-brand-700 rounded-lg hover:bg-brand-50 text-sm font-medium transition-colors">
Edit Staffing Presets
</button>
)}
<button
type="button"
onClick={onToggleGlobal}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 ${
blueprint.isGlobal
? "border border-amber-300 text-amber-700 hover:bg-amber-50"
: "border border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
title={blueprint.isGlobal ? "Remove from global columns" : "Make fields available as global columns"}
>
{blueprint.isGlobal ? "Unglobalize" : "Make Global"}
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete blueprint "${blueprint.name}"?`)) {
onDelete();
}
}}
className="px-3 py-1.5 border border-red-200 text-red-600 rounded-lg hover:bg-red-50 text-sm font-medium transition-colors disabled:opacity-50"
>
Delete
</button>
</div>
</div>
);
}
export function BlueprintsClient() {
const [showNewModal, setShowNewModal] = useState(false);
const [editingBlueprint, setEditingBlueprint] = useState<BlueprintRow | null>(null);
const [editingTab, setEditingTab] = useState<"fields" | "presets">("fields");
const [targetFilter, setTargetFilter] = useState<string>("");
const [confirmBatchDelete, setConfirmBatchDelete] = useState<string[] | null>(null);
const selection = useSelection();
const utils = trpc.useUtils();
const { data, isLoading, isError } = trpc.blueprint.list.useQuery({
target: (targetFilter as BlueprintTarget) || undefined,
});
const batchDeleteMutation = trpc.blueprint.batchDelete.useMutation();
const deleteMutation = trpc.blueprint.delete.useMutation();
const setGlobalMutation = trpc.blueprint.setGlobal.useMutation();
const viewPrefs = useViewPrefs("blueprints");
useEffect(() => {
selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetFilter]);
const blueprints: BlueprintRow[] = data ?? [];
const { sorted: sortedBlueprints, sortField, sortDir, toggle } = useTableSort<BlueprintRow, BlueprintSortField>(blueprints, {
initialField: (viewPrefs.savedSort?.field as BlueprintSortField | undefined) ?? null,
initialDir: viewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
const blueprintIds = sortedBlueprints.map((b) => b.id);
function handleSort(field: BlueprintSortField) {
switch (field) {
case "fieldCount":
toggle(field, (row) => (Array.isArray(row.fieldDefs) ? row.fieldDefs.length : 0));
return;
case "presetCount":
toggle(field, (row) => (Array.isArray(row.rolePresets) ? row.rolePresets.length : 0));
return;
case "global":
toggle(field, (row) => (row.isGlobal ? 0 : 1));
return;
default:
toggle(field);
}
}
function handleSortRequest(field: string) {
handleSort(field as BlueprintSortField);
}
async function handleDelete(id: string) {
await deleteMutation.mutateAsync({ id });
await utils.blueprint.list.invalidate();
if (selection.selectedIds.has(id)) {
selection.toggle(id);
}
if (editingBlueprint?.id === id) {
setEditingBlueprint(null);
}
}
async function handleToggleGlobal(id: string, isGlobal: boolean | undefined) {
await setGlobalMutation.mutateAsync({ id, isGlobal: !isGlobal });
await utils.blueprint.list.invalidate();
}
async function handleBatchDelete(ids: string[]) {
await batchDeleteMutation.mutateAsync({ ids });
await utils.blueprint.list.invalidate();
selection.clear();
}
return (
<div className="p-6 pb-24">
<div className="flex items-start justify-between mb-6 gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Blueprints</h1>
<p className="text-gray-500 text-sm mt-1">Configure dynamic fields for resources and projects</p>
</div>
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
+ New Blueprint
</button>
</div>
<FilterBar
hasActiveFilters={!!targetFilter}
onClearFilters={() => setTargetFilter("")}
>
<select
value={targetFilter}
onChange={(e) => setTargetFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
>
<option value="">All Targets</option>
<option value="RESOURCE">Resource</option>
<option value="PROJECT">Project</option>
</select>
</FilterBar>
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-5 animate-pulse h-36" />
))}
</div>
)}
{isError && (
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-red-700 text-sm">
Failed to load blueprints. Please refresh the page.
</div>
)}
{!isLoading && !isError && blueprints.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-gray-400 text-sm mb-4">No blueprints yet. Create one to start defining dynamic fields.</p>
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
+ New Blueprint
</button>
</div>
)}
{!isLoading && !isError && sortedBlueprints.length > 0 && (
<>
<div className="hidden md:block bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="w-12 px-3 py-3">
<input
type="checkbox"
checked={selection.isAllSelected(blueprintIds)}
onChange={() => selection.toggleAll(blueprintIds)}
className="rounded border-gray-300"
aria-label="Select all blueprints"
/>
</th>
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} />
<SortableColumnHeader label="Target" field="target" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} />
<SortableColumnHeader label="Fields" field="fieldCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
<SortableColumnHeader label="Staffing Presets" field="presetCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
<SortableColumnHeader label="Global" field="global" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{sortedBlueprints.map((bp) => {
const fieldCount = Array.isArray(bp.fieldDefs) ? bp.fieldDefs.length : 0;
const presetCount = Array.isArray(bp.rolePresets) ? bp.rolePresets.length : 0;
const isProject = bp.target === "PROJECT";
return (
<tr key={bp.id} className="border-b border-gray-100 last:border-b-0 hover:bg-gray-50 transition-colors">
<td className="px-3 py-3">
<input
type="checkbox"
checked={selection.selectedIds.has(bp.id)}
onChange={() => selection.toggle(bp.id)}
className="rounded border-gray-300"
/>
</td>
<td className="px-3 py-3">
<div className="min-w-0">
<div className="font-medium text-gray-900">{bp.name}</div>
{bp.description && <div className="text-xs text-gray-500 mt-0.5 truncate">{bp.description}</div>}
</div>
</td>
<td className="px-3 py-3">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700" : "bg-blue-50 text-blue-700"}`}>
{bp.target}
</span>
</td>
<td className="px-3 py-3 text-center text-gray-600">{fieldCount}</td>
<td className="px-3 py-3 text-center text-gray-600">{isProject ? presetCount : "—"}</td>
<td className="px-3 py-3 text-center">
{bp.isGlobal ? (
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 font-medium">
Global
</span>
) : (
<span className="text-gray-300"></span>
)}
</td>
<td className="px-3 py-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => { setEditingTab("fields"); setEditingBlueprint(bp); }}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit Fields
</button>
{isProject && (
<button
type="button"
onClick={() => { setEditingTab("presets"); setEditingBlueprint(bp); }}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Presets
</button>
)}
<button
type="button"
onClick={() => handleToggleGlobal(bp.id, bp.isGlobal)}
disabled={setGlobalMutation.isPending}
className="text-xs text-gray-500 hover:text-gray-700 disabled:opacity-50"
>
{bp.isGlobal ? "Unglobalize" : "Make Global"}
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete blueprint "${bp.name}"?`)) {
handleDelete(bp.id);
}
}}
disabled={deleteMutation.isPending}
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
>
Delete
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="grid grid-cols-1 gap-4 md:hidden">
{sortedBlueprints.map((bp) => (
<BlueprintCard
key={bp.id}
blueprint={bp}
onEditFields={() => { setEditingTab("fields"); setEditingBlueprint(bp); }}
onEditStaffing={() => { setEditingTab("presets"); setEditingBlueprint(bp); }}
onToggleGlobal={() => handleToggleGlobal(bp.id, bp.isGlobal)}
onDelete={() => handleDelete(bp.id)}
isSelected={selection.selectedIds.has(bp.id)}
onToggleSelect={() => selection.toggle(bp.id)}
/>
))}
</div>
</>
)}
<BatchActionBar
count={selection.count}
onClear={selection.clear}
actions={[
{
label: `Delete (${selection.count})`,
variant: "danger",
onClick: () => setConfirmBatchDelete(selection.selectedArray),
disabled: batchDeleteMutation.isPending,
},
]}
/>
{confirmBatchDelete && (
<ConfirmDialog
title="Delete Blueprints"
message={`Delete ${confirmBatchDelete.length} selected blueprint${confirmBatchDelete.length !== 1 ? "s" : ""}? They will be marked as inactive.`}
confirmLabel="Delete All"
variant="danger"
onConfirm={() => {
void handleBatchDelete(confirmBatchDelete);
setConfirmBatchDelete(null);
}}
onCancel={() => setConfirmBatchDelete(null)}
/>
)}
{showNewModal && (
<NewBlueprintModal onClose={() => setShowNewModal(false)} onCreated={() => setShowNewModal(false)} />
)}
{editingBlueprint && (
<BlueprintFieldEditor
blueprintId={editingBlueprint.id}
blueprintName={editingBlueprint.name}
initialFieldDefs={Array.isArray(editingBlueprint.fieldDefs) ? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[]) : []}
initialRolePresets={Array.isArray(editingBlueprint.rolePresets) ? (editingBlueprint.rolePresets as import("@planarchy/shared").StaffingRequirement[]) : []}
initialTab={editingTab}
onClose={() => setEditingBlueprint(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,206 @@
"use client";
import { useState } from "react";
import type { StaffingRequirement } from "@planarchy/shared";
const INPUT_CLS =
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
const BTN_DANGER =
"px-2 py-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded text-sm transition-colors";
function makeEmptyPreset(): StaffingRequirement {
return {
id: crypto.randomUUID(),
role: "",
requiredSkills: [],
preferredSkills: [],
hoursPerDay: 8,
headcount: 1,
};
}
interface PresetRowProps {
preset: StaffingRequirement;
onChange: (preset: StaffingRequirement) => void;
onDelete: () => void;
}
function PresetRow({ preset, onChange, onDelete }: PresetRowProps) {
function update<K extends keyof StaffingRequirement>(key: K, value: StaffingRequirement[K]) {
onChange({ ...preset, [key]: value });
}
return (
<div className="border border-gray-200 rounded-lg p-3 bg-white">
<div className="flex flex-wrap items-center gap-2">
{/* Role */}
<input
type="text"
value={preset.role}
onChange={(e) => update("role", e.target.value)}
placeholder="Role name"
className={`${INPUT_CLS} flex-1 min-w-32`}
aria-label="Role name"
/>
{/* Required Skills */}
<div className="flex flex-col gap-0.5 flex-1 min-w-40">
<label className="text-xs text-gray-400">Required skills (comma-sep)</label>
<input
type="text"
value={preset.requiredSkills.join(", ")}
onChange={(e) =>
update(
"requiredSkills",
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="e.g. 3D Modeling, Lighting"
className={INPUT_CLS}
aria-label="Required skills"
/>
</div>
{/* Hours per day */}
<div className="flex flex-col gap-0.5 w-24">
<label className="text-xs text-gray-400">h/day</label>
<input
type="number"
value={preset.hoursPerDay}
min={0}
max={24}
step={0.5}
onChange={(e) => update("hoursPerDay", parseFloat(e.target.value) || 0)}
className={INPUT_CLS}
aria-label="Hours per day"
/>
</div>
{/* Headcount */}
<div className="flex flex-col gap-0.5 w-20">
<label className="text-xs text-gray-400">Count</label>
<input
type="number"
value={preset.headcount}
min={1}
max={20}
onChange={(e) => update("headcount", parseInt(e.target.value, 10) || 1)}
className={INPUT_CLS}
aria-label="Headcount"
/>
</div>
{/* Delete */}
<button
type="button"
onClick={onDelete}
className={`${BTN_DANGER} self-end mb-0.5`}
aria-label="Remove preset"
>
×
</button>
</div>
{/* Preferred skills (secondary row) */}
<div className="mt-2">
<label className="text-xs text-gray-400">Preferred skills (optional)</label>
<input
type="text"
value={(preset.preferredSkills ?? []).join(", ")}
onChange={(e) =>
update(
"preferredSkills",
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="e.g. Compositing, Art Direction"
className={`${INPUT_CLS} w-full mt-0.5`}
aria-label="Preferred skills"
/>
</div>
</div>
);
}
interface RolePresetsEditorProps {
initialPresets: StaffingRequirement[];
/** Called with the current presets array when the user clicks Save */
onSave: (presets: StaffingRequirement[]) => void;
isSaving?: boolean;
saveError?: string | null;
}
export function RolePresetsEditor({
initialPresets,
onSave,
isSaving = false,
saveError = null,
}: RolePresetsEditorProps) {
const [presets, setPresets] = useState<StaffingRequirement[]>(initialPresets);
function addPreset() {
setPresets((prev) => [...prev, makeEmptyPreset()]);
}
function removePreset(idx: number) {
setPresets((prev) => prev.filter((_, i) => i !== idx));
}
function updatePreset(idx: number, updated: StaffingRequirement) {
setPresets((prev) => prev.map((p, i) => (i === idx ? updated : p)));
}
return (
<div>
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
{presets.length === 0 && (
<p className="text-sm text-gray-400 text-center py-8">
No role presets yet. Click &ldquo;+ Add Role&rdquo; to define default staffing.
</p>
)}
{presets.map((preset, idx) => (
<PresetRow
key={preset.id}
preset={preset}
onChange={(updated) => updatePreset(idx, updated)}
onDelete={() => removePreset(idx)}
/>
))}
</div>
<div className="mt-3">
<button
type="button"
onClick={addPreset}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium"
>
<span className="text-lg leading-none">+</span> Add Role
</button>
</div>
{saveError && (
<div className="mt-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{saveError}
</div>
)}
<div className="flex justify-end mt-4">
<button
type="button"
onClick={() => onSave(presets)}
disabled={isSaving}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isSaving ? "Saving…" : "Save Presets"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,60 @@
"use client";
import type { DashboardWidgetType } from "@planarchy/shared/types";
import { WIDGET_CATALOG } from "./widget-registry.js";
interface AddWidgetModalProps {
onAdd: (type: DashboardWidgetType) => void;
onClose: () => void;
}
export function AddWidgetModal({ onAdd, onClose }: AddWidgetModalProps) {
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Add Widget</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
>
×
</button>
</div>
{/* Grid of widgets */}
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{WIDGET_CATALOG.map((def) => (
<button
key={def.type}
type="button"
onClick={() => {
onAdd(def.type);
onClose();
}}
className="flex items-start gap-4 p-4 border border-gray-200 rounded-xl hover:border-brand-400 hover:bg-brand-50 transition-colors text-left"
>
<span className="text-3xl shrink-0">{def.icon}</span>
<div>
<div className="font-semibold text-gray-900 text-sm">{def.label}</div>
<div className="text-xs text-gray-500 mt-1">{def.description}</div>
<div className="text-xs text-gray-400 mt-1">
Default: {def.defaultSize.w}×{def.defaultSize.h} grid units
</div>
</div>
</button>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,160 @@
"use client";
import type { DashboardWidgetConfig, DashboardWidgetType } from "@planarchy/shared/types";
import dynamic from "next/dynamic";
import { Suspense, useState, useRef, useEffect } from "react";
import { useDashboardLayout } from "~/hooks/useDashboardLayout.js";
import { WidgetContainer } from "./WidgetContainer.js";
import { AddWidgetModal } from "./AddWidgetModal.js";
import { getWidget } from "./widget-registry.js";
// Import CSS for react-grid-layout
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
function WidgetFallback() {
return (
<div className="animate-pulse h-full w-full flex flex-col gap-3 p-4">
<div className="h-3 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-full bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-3 w-4/5 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-3 w-3/5 bg-gray-100 dark:bg-gray-800 rounded" />
</div>
);
}
// Dynamic import — no WidthProvider (uses findDOMNode, broken in React 18 strict mode).
// We measure container width ourselves via ResizeObserver and pass it as a prop.
const GridLayout = dynamic(() => import("react-grid-layout").then((m) => m.Responsive), {
ssr: false,
});
function renderWidget(type: DashboardWidgetType, config: DashboardWidgetConfig, onConfigChange: (u: Record<string, unknown>) => void) {
const widget = getWidget(type);
const Component = widget.component;
return (
<Suspense fallback={<WidgetFallback />}>
<Component config={config as Record<string, unknown>} onConfigChange={onConfigChange} />
</Suspense>
);
}
export function DashboardClient() {
const [addModalOpen, setAddModalOpen] = useState(false);
const { config, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout } =
useDashboardLayout();
// Measure grid container width so Responsive knows the column size.
// We can't use WidthProvider (uses findDOMNode, deprecated in React 18).
const containerRef = useRef<HTMLDivElement>(null);
const [gridWidth, setGridWidth] = useState(1200);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
if (entry) setGridWidth(entry.contentRect.width);
});
ro.observe(el);
setGridWidth(el.getBoundingClientRect().width);
return () => ro.disconnect();
}, []);
const layouts = {
lg: config.widgets.map((w) => ({
i: w.id,
x: w.x,
y: w.y,
w: w.w,
h: w.h,
minW: w.minW ?? 2,
minH: w.minH ?? 2,
})),
};
return (
<div className="p-6">
{/* Toolbar */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-sm text-gray-500 mt-1">Drag to rearrange, resize from corners</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={resetLayout}
className="px-3 py-2 text-sm text-gray-500 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Reset
</button>
<button
type="button"
onClick={() => setAddModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Widget
</button>
</div>
</div>
{/* Grid */}
{config.widgets.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center border-2 border-dashed border-gray-200 rounded-xl">
<p className="text-gray-400 text-sm mb-4">No widgets yet. Add your first widget to get started.</p>
<button
type="button"
onClick={() => setAddModalOpen(true)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
+ Add Widget
</button>
</div>
) : (
<div ref={containerRef}>
{(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const AnyGridLayout = GridLayout as any;
return (
<AnyGridLayout
className="layout"
layouts={layouts}
width={gridWidth}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
rowHeight={80}
compactType={null}
preventCollision={false}
onLayoutChange={(_: unknown, allLayouts: Record<string, { i: string; x: number; y: number; w: number; h: number }[]>) => onLayoutChange(allLayouts["lg"] ?? [])}
draggableHandle=".widget-drag-handle"
margin={[12, 12]}
>
{config.widgets.map((widget) => (
<div key={widget.id}>
<WidgetContainer
title={widget.title ?? getWidget(widget.type).label}
onRemove={() => removeWidget(widget.id)}
>
{renderWidget(
widget.type,
widget.config,
(update) => updateWidgetConfig(widget.id, update),
)}
</WidgetContainer>
</div>
))}
</AnyGridLayout>
);
})()}
</div>
)}
{addModalOpen && (
<AddWidgetModal onAdd={addWidget} onClose={() => setAddModalOpen(false)} />
)}
</div>
);
}
@@ -0,0 +1,39 @@
"use client";
interface WidgetContainerProps {
title: string;
onRemove: () => void;
children: React.ReactNode;
isDragging?: boolean;
}
export function WidgetContainer({ title, onRemove, children, isDragging }: WidgetContainerProps) {
return (
<div
className={`flex flex-col h-full bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden ${
isDragging ? "shadow-lg border-brand-300" : ""
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-100 bg-gray-50/50 shrink-0 cursor-grab active:cursor-grabbing widget-drag-handle">
<span className="text-sm font-semibold text-gray-700 truncate">{title}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="ml-2 p-1 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded transition-colors shrink-0"
title="Remove widget"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-4">{children}</div>
</div>
);
}
@@ -0,0 +1,60 @@
import {
DASHBOARD_WIDGET_CATALOG,
type DashboardWidgetCatalogEntry,
type DashboardWidgetType,
} from "@planarchy/shared/types";
import { lazy, type ComponentType, type LazyExoticComponent } from "react";
type WidgetUpdate = Record<string, unknown>;
export interface WidgetProps {
config: Record<string, unknown>;
onConfigChange?: (update: WidgetUpdate) => void;
}
export type WidgetComponent = LazyExoticComponent<ComponentType<WidgetProps>>;
export interface WidgetDefinition extends DashboardWidgetCatalogEntry {
component: WidgetComponent;
}
export const WIDGET_CATALOG = DASHBOARD_WIDGET_CATALOG;
export const WIDGET_REGISTRY: Record<DashboardWidgetType, WidgetDefinition> = {
"stat-cards": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "stat-cards")!,
component: lazy(() => import("./widgets/StatCardsWidget.js").then((m) => ({ default: m.StatCardsWidget }))),
},
"resource-table": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "resource-table")!,
component: lazy(() => import("./widgets/ResourceTableWidget.js").then((m) => ({ default: m.ResourceTableWidget }))),
},
"project-table": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "project-table")!,
component: lazy(() => import("./widgets/ProjectTableWidget.js").then((m) => ({ default: m.ProjectTableWidget }))),
},
"peak-times-chart": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "peak-times-chart")!,
component: lazy(() => import("./widgets/PeakTimesWidget.js").then((m) => ({ default: m.PeakTimesWidget }))),
},
"demand-view": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "demand-view")!,
component: lazy(() => import("./widgets/DemandWidget.js").then((m) => ({ default: m.DemandWidget }))),
},
"top-value-resources": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "top-value-resources")!,
component: lazy(() => import("./widgets/TopValueWidget.js").then((m) => ({ default: m.TopValueWidget }))),
},
"chargeability-overview": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "chargeability-overview")!,
component: lazy(() => import("./widgets/ChargeabilityWidget.js").then((m) => ({ default: m.ChargeabilityWidget }))),
},
};
export function getWidget(type: DashboardWidgetType): WidgetDefinition {
return WIDGET_REGISTRY[type];
}
export function getAllWidgets(): WidgetDefinition[] {
return Object.values(WIDGET_REGISTRY);
}
@@ -0,0 +1,224 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
type TopSortKey = "name" | "actual" | "expected";
type WatchSortKey = "name" | "actual" | "target";
export function ChargeabilityWidget({ config: _config }: WidgetProps) {
const config = _config as { topN?: number; watchlistThreshold?: number };
const [topSort, setTopSort] = useState<TopSortKey>("actual");
const [topDir, setTopDir] = useState<"asc" | "desc">("desc");
const [watchSort, setWatchSort] = useState<WatchSortKey>("actual");
const [watchDir, setWatchDir] = useState<"asc" | "desc">("asc");
function toggleTop(key: TopSortKey) {
if (topSort === key) setTopDir((d) => (d === "asc" ? "desc" : "asc"));
else { setTopSort(key); setTopDir(key === "name" ? "asc" : "desc"); }
}
function toggleWatch(key: WatchSortKey) {
if (watchSort === key) setWatchDir((d) => (d === "asc" ? "desc" : "asc"));
else { setWatchSort(key); setWatchDir(key === "name" ? "asc" : "asc"); }
}
const { data, isLoading } = trpc.dashboard.getChargeabilityOverview.useQuery(
{ topN: config.topN ?? 10, watchlistThreshold: config.watchlistThreshold ?? 15 },
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
if (isLoading) {
return (
<div className="animate-pulse flex flex-col gap-3 pt-1">
<div className="h-2 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
{[...Array(4)].map((_, i) => (
<div key={i} className="flex gap-3 px-2 py-1">
<div className="h-3 w-4 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
<div className="border-t border-gray-100 dark:border-gray-800 mt-1 pt-2">
<div className="h-2 w-20 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
{[...Array(3)].map((_, i) => (
<div key={i} className="flex gap-3 px-2 py-1">
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
</div>
))}
</div>
</div>
);
}
const rawTop = data?.top ?? [];
const rawWatch = data?.watchlist ?? [];
const month = data?.month ?? "";
const top = [...rawTop].sort((a, b) => {
const mult = topDir === "asc" ? 1 : -1;
switch (topSort) {
case "name": return mult * a.displayName.localeCompare(b.displayName);
case "actual": return mult * (a.actualChargeability - b.actualChargeability);
case "expected": return mult * (a.expectedChargeability - b.expectedChargeability);
default: return 0;
}
});
const watchlist = [...rawWatch].sort((a, b) => {
const mult = watchDir === "asc" ? 1 : -1;
switch (watchSort) {
case "name": return mult * a.displayName.localeCompare(b.displayName);
case "actual": return mult * (a.actualChargeability - b.actualChargeability);
case "target": return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
default: return 0;
}
});
function TopInd({ k }: { k: TopSortKey }) {
return topSort === k
? <span className="text-[10px] ml-0.5">{topDir === "asc" ? "▲" : "▼"}</span>
: <span className="text-[10px] ml-0.5 text-gray-300"></span>;
}
function WatchInd({ k }: { k: WatchSortKey }) {
return watchSort === k
? <span className="text-[10px] ml-0.5">{watchDir === "asc" ? "▲" : "▼"}</span>
: <span className="text-[10px] ml-0.5 text-gray-300"></span>;
}
return (
<div className="h-full flex flex-col gap-2 overflow-hidden">
{month && (
<p className="text-xs text-gray-400 px-1 flex-shrink-0 flex items-center gap-1">
Period: {month}
<InfoTooltip
content="Chargeability is calculated for the current calendar month. Available hours are based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
width="w-72"
/>
</p>
)}
{/* Top list */}
<section className="flex-1 min-h-0 overflow-auto">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
Top Chargeability
</h3>
{top.length === 0 ? (
<p className="text-xs text-gray-400 px-1">No data available.</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="px-2 py-1 text-left font-medium w-6">#</th>
<th className="px-2 py-1 text-left font-medium">
<button type="button" onClick={() => toggleTop("name")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Name<TopInd k="name" />
</button>
</th>
<th className="px-2 py-1 text-right font-medium">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleTop("actual")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Actual<TopInd k="actual" />
</button>
<InfoTooltip
content="CONFIRMED + ACTIVE allocations on non-DRAFT projects ÷ available working hours this month × 100."
width="w-72"
/>
</span>
</th>
<th className="px-2 py-1 text-right font-medium">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleTop("expected")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Expected<TopInd k="expected" />
</button>
<InfoTooltip
content="All non-CANCELLED allocations (including DRAFT projects and PROPOSED status) ÷ available working hours this month × 100."
width="w-72"
/>
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{top.map((r, i) => (
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
<td className="px-2 py-1 text-gray-800 truncate max-w-[120px]">
<span title={r.displayName}>{r.displayName}</span>
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
</td>
<td className="px-2 py-1 text-right font-semibold text-green-700">
{r.actualChargeability}%
</td>
<td className="px-2 py-1 text-right text-gray-400">
{r.expectedChargeability}%
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<div className="border-t border-gray-100 flex-shrink-0" />
{/* Watchlist */}
<section className="flex-1 min-h-0 overflow-auto">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
Watchlist <span className="font-normal text-gray-400">(below target)</span>
</h3>
{watchlist.length === 0 ? (
<p className="text-xs text-gray-400 px-1">All resources at or near target.</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="px-2 py-1 text-left font-medium">
<button type="button" onClick={() => toggleWatch("name")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Name<WatchInd k="name" />
</button>
</th>
<th className="px-2 py-1 text-right font-medium">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleWatch("actual")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Actual<WatchInd k="actual" />
</button>
<InfoTooltip content="Actual chargeability this month: CONFIRMED + ACTIVE allocations on non-DRAFT projects ÷ available hours." />
</span>
</th>
<th className="px-2 py-1 text-right font-medium">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleWatch("target")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Target<WatchInd k="target" />
</button>
<InfoTooltip content="Chargeability target set by management. Watchlist shows resources more than 15 percentage points below their target." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{watchlist.map((r) => (
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-2 py-1 text-gray-800 truncate max-w-[140px]">
<span title={r.displayName}>{r.displayName}</span>
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
</td>
<td className="px-2 py-1 text-right font-semibold text-red-600">
{r.actualChargeability}%
</td>
<td className="px-2 py-1 text-right text-gray-400">
{r.chargeabilityTarget}%
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
);
}
@@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
type GroupBy = "project" | "person" | "chapter";
export function DemandWidget({ config, onConfigChange }: WidgetProps) {
const groupBy = (config.groupBy as GroupBy) || "project";
type SortKey = "name" | "allocatedHours" | "requiredFTEs" | "resourceCount";
const [sortKey, setSortKey] = useState<SortKey>("allocatedHours");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir(key === "name" ? "asc" : "desc"); }
}
const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
const endDate = new Date(now.getFullYear(), now.getMonth() + 3, 0).toISOString();
const { data, isLoading, isFetching } = trpc.dashboard.getDemand.useQuery(
{ startDate, endDate, groupBy },
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
if (isLoading && !data) {
return (
<div className="animate-pulse flex flex-col gap-3 pt-1">
<div className="flex gap-1 border-b border-gray-200 pb-1">
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
{[...Array(6)].map((_, i) => (
<div key={i} className="flex gap-3 px-3 py-1.5">
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
);
}
const rows = data ?? [];
const sorted = [...rows].sort((a, b) => {
const mult = sortDir === "asc" ? 1 : -1;
switch (sortKey) {
case "name": return mult * a.name.localeCompare(b.name);
case "allocatedHours": return mult * (a.allocatedHours - b.allocatedHours);
case "requiredFTEs": return mult * ((a.requiredFTEs as unknown as number ?? 0) - (b.requiredFTEs as unknown as number ?? 0));
case "resourceCount": return mult * (a.resourceCount - b.resourceCount);
default: return 0;
}
});
function Ind({ k }: { k: SortKey }) {
return sortKey === k
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "▲" : "▼"}</span>
: <span className="text-[10px] ml-0.5 text-gray-300"></span>;
}
return (
<div className="flex flex-col h-full gap-3">
{/* Tab bar */}
<div className="flex gap-1 border-b border-gray-200">
{(["project", "person", "chapter"] as GroupBy[]).map((g) => (
<button
key={g}
type="button"
onClick={() => onConfigChange?.({ groupBy: g })}
className={`px-3 py-1.5 text-xs font-medium capitalize transition-colors ${
groupBy === g
? "border-b-2 border-brand-600 text-brand-700"
: "text-gray-500 hover:text-gray-700"
}`}
>
Per {g === "person" ? "Person" : g === "project" ? "Project" : "Chapter"}
</button>
))}
</div>
{/* Table */}
<div className={`overflow-auto flex-1 transition-opacity duration-150 ${isFetching ? "opacity-60" : "opacity-100"}`}>
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
{groupBy === "project" ? "Project" : groupBy === "person" ? "Person" : "Chapter"}
<Ind k="name" />
</button>
</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("allocatedHours")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Allocated h<Ind k="allocatedHours" />
</button>
<InfoTooltip
content="Total booked hours from active assignments in the current quarter."
position="bottom"
/>
</span>
</th>
{groupBy === "project" && (
<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("requiredFTEs")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Req. FTEs<Ind k="requiredFTEs" />
</button>
<InfoTooltip
content="Planned demand from demand requirements, with fallback to project staffing requirements for legacy projects. Red = booked hours fall short of the planned demand."
position="bottom"
/>
</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("resourceCount")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
{groupBy === "person" ? "Projects" : "Resources"}<Ind k="resourceCount" />
</button>
{groupBy === "person" ? (
<InfoTooltip content="Number of distinct projects this person is allocated to in the period." position="bottom" />
) : (
<InfoTooltip content="Number of distinct resources allocated to this project/chapter in the period." position="bottom" />
)}
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900 max-w-[200px] truncate">
{groupBy === "project" ? (
<span><span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>{row.name}</span>
) : (
row.name
)}
</td>
<td className="px-3 py-2 text-right text-gray-700">{row.allocatedHours}h</td>
{groupBy === "project" && (
<td className="px-3 py-2 text-right text-gray-700">
{(() => {
const ftes = row.requiredFTEs as unknown as number;
return ftes > 0 ? (
<span className={row.allocatedHours / 8 < ftes * 22 * 3 ? "text-red-600 font-semibold" : "text-green-700"}>
{ftes} FTE
</span>
) : "—";
})()}
</td>
)}
<td className="px-3 py-2 text-right text-gray-500">{row.resourceCount}</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 && (
<div className="text-center py-8 text-sm text-gray-400">No demand data found.</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,138 @@
"use client";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceLine,
ResponsiveContainer,
Legend,
} from "recharts";
const COLORS = [
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6",
];
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
const granularity = (config.granularity as "week" | "month") || "month";
const groupBy = (config.groupBy as "project" | "chapter" | "resource") || "project";
const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString();
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0).toISOString();
const { data, isLoading } = trpc.dashboard.getPeakTimes.useQuery(
{ startDate, endDate, granularity, groupBy },
{ staleTime: 120_000, placeholderData: (prev) => prev },
);
if (isLoading) {
return (
<div className="animate-pulse flex flex-col gap-3 h-full pt-2">
<div className="flex gap-2">
<div className="h-7 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
<div className="h-7 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
<div className="flex items-end gap-1 flex-1 px-2">
{[...Array(12)].map((_, i) => (
<div
key={i}
className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-t"
style={{ height: `${30 + Math.random() * 50}%` }}
/>
))}
</div>
</div>
);
}
const periods = data ?? [];
// Collect all group names
const allGroups = new Set<string>();
for (const p of periods) {
for (const g of p.groups) allGroups.add(g.name);
}
const groups = [...allGroups].slice(0, 10);
// Build recharts data
const chartData = periods.map((p) => {
const row: Record<string, number | string> = { period: p.period, capacity: p.capacityHours };
for (const g of p.groups) {
row[g.name] = g.hours;
}
return row;
});
return (
<div className="flex flex-col h-full gap-3">
{/* Controls + info */}
<div className="flex gap-2 items-center">
<select
value={granularity}
onChange={(e) => onConfigChange?.({ granularity: e.target.value })}
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="month">Monthly</option>
<option value="week">Weekly</option>
</select>
<select
value={groupBy}
onChange={(e) => onConfigChange?.({ groupBy: e.target.value })}
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="project">By Project</option>
<option value="chapter">By Chapter</option>
<option value="resource">By Resource</option>
</select>
<InfoTooltip
content={
<span>
Stacked bars = booked hours per group per period (last 2 months to next 6 months).<br />
Red dashed line = total capacity estimate (all active resources × available hours per day × working days).<br />
Bars exceeding the capacity line indicate over-allocation risk.
</span>
}
width="w-80"
position="bottom"
/>
</div>
{/* Chart */}
<div className="flex-1 min-h-0">
{chartData.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No allocation data in selected period.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="period" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<ReferenceLine
{...({ dataKey: "capacity" } as any)}
stroke="#ef4444"
strokeDasharray="5 5"
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }}
/>
{groups.map((g, i) => (
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} />
))}
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
@@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { ProjectStatus } from "@planarchy/shared/types";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const STATUS_COLORS: Record<string, string> = {
DRAFT: "bg-gray-100 text-gray-700",
ACTIVE: "bg-green-100 text-green-700",
ON_HOLD: "bg-yellow-100 text-yellow-700",
COMPLETED: "bg-blue-100 text-blue-700",
CANCELLED: "bg-red-100 text-red-700",
};
export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
const status = (config.status as ProjectStatus) || undefined;
const search = (config.search as string) || "";
const { data: projects, isLoading } = trpc.project.listWithCosts.useQuery(
{ status, search: search || undefined },
{ staleTime: 60_000 },
);
type SortKey = "code" | "name" | "status" | "cost" | "personDays";
const [sortKey, setSortKey] = useState<SortKey>("name");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
}
if (isLoading) {
return (
<div className="animate-pulse flex flex-col gap-2 pt-1">
{/* header row */}
<div className="flex gap-3 px-3 py-2">
{[40, 120, 80, 60, 60].map((w, i) => (
<div key={i} className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded" style={{ width: w }} />
))}
</div>
{/* data rows */}
{[...Array(6)].map((_, i) => (
<div key={i} className="flex gap-3 px-3 py-2 border-t border-gray-100 dark:border-gray-800">
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
);
}
interface ProjectRow {
id: string;
shortCode: string;
name: string;
status: string;
totalCostCents: number;
totalPersonDays: number;
}
const list = ((projects as unknown as { projects: ProjectRow[] } | undefined)?.projects ?? []) as ProjectRow[];
const sorted = [...list].sort((a, b) => {
const mult = sortDir === "asc" ? 1 : -1;
switch (sortKey) {
case "code": return mult * a.shortCode.localeCompare(b.shortCode);
case "name": return mult * a.name.localeCompare(b.name);
case "status": return mult * a.status.localeCompare(b.status);
case "cost": return mult * (a.totalCostCents - b.totalCostCents);
case "personDays": return mult * (a.totalPersonDays - b.totalPersonDays);
default: return 0;
}
});
return (
<div className="flex flex-col h-full gap-3">
{/* Filters */}
<div className="flex gap-2">
<input
type="search"
placeholder="Search projects..."
value={search}
onChange={(e) => onConfigChange?.({ search: e.target.value })}
className="flex-1 min-w-0 px-2 py-1 text-xs border border-gray-300 rounded-lg"
/>
<select
value={status ?? ""}
onChange={(e) => onConfigChange?.({ status: e.target.value || undefined })}
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="">All Statuses</option>
{Object.values(ProjectStatus).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{/* Table */}
<div className="overflow-auto flex-1">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("code")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Code
<span className="text-[10px] ml-0.5">{sortKey === "code" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Name
<span className="text-[10px] ml-0.5">{sortKey === "name" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("status")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Status
<span className="text-[10px] ml-0.5">{sortKey === "status" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
</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("cost")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Cost
<span className="text-[10px] ml-0.5">{sortKey === "cost" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
<InfoTooltip
content="Sum of (resource LCR × hours per day × working days) across all non-cancelled allocations on this project."
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("personDays")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Person Days
<span className="text-[10px] ml-0.5">{sortKey === "personDays" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
<InfoTooltip content="Total working days allocated across all non-cancelled allocations (sum of allocation durations in working days)." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.map((p) => (
<tr key={p.id} className="hover:bg-gray-50">
<td className="px-3 py-2 font-mono text-gray-600">{p.shortCode}</td>
<td className="px-3 py-2 font-medium text-gray-900 max-w-[180px] truncate">{p.name}</td>
<td className="px-3 py-2">
<span className={`inline-block px-1.5 py-0.5 rounded-full text-xs ${STATUS_COLORS[p.status] ?? ""}`}>
{p.status}
</span>
</td>
<td className="px-3 py-2 text-right text-gray-700">
{(p.totalCostCents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })}
</td>
<td className="px-3 py-2 text-right text-gray-700">{p.totalPersonDays}d</td>
</tr>
))}
</tbody>
</table>
{list.length === 0 && (
<div className="text-center py-8 text-sm text-gray-400">No projects found.</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,182 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
interface ResourceRow {
id: string;
eid: string;
displayName: string;
chapter: string | null;
chargeabilityTarget: number;
bookingCount: number;
utilizationPercent: number;
isOverbooked: boolean;
}
export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
const chapter = (config.chapter as string) || "";
const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
const endDate = new Date(now.getFullYear(), now.getMonth() + 3, 0).toISOString();
const { data: resources, isLoading } = trpc.resource.listWithUtilization.useQuery(
{ chapter: chapter || undefined, startDate, endDate },
{ staleTime: 60_000 },
);
const { data: chapterData } = trpc.resource.chapters.useQuery(undefined, { staleTime: 120_000 });
const chapters = chapterData ?? [];
type SortKey = "eid" | "name" | "chapter" | "bookings" | "utilization" | "target";
const [sortKey, setSortKey] = useState<SortKey>("name");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
}
if (isLoading) {
return (
<div className="animate-pulse flex flex-col gap-2 pt-1">
{/* header row */}
<div className="flex gap-3 px-3 py-2">
{[40, 120, 80, 60, 60].map((w, i) => (
<div key={i} className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded" style={{ width: w }} />
))}
</div>
{/* data rows */}
{[...Array(6)].map((_, i) => (
<div key={i} className="flex gap-3 px-3 py-2 border-t border-gray-100 dark:border-gray-800">
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
);
}
const list = (resources ?? []) as unknown as ResourceRow[];
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 "bookings": return mult * (a.bookingCount - b.bookingCount);
case "utilization": return mult * (a.utilizationPercent - b.utilizationPercent);
case "target": return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
default: return 0;
}
});
return (
<div className="flex flex-col h-full gap-3">
{/* Filter */}
{chapters.length > 0 && (
<select
value={chapter}
onChange={(e) => onConfigChange?.({ chapter: e.target.value })}
className="w-40 px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="">All Chapters</option>
{chapters.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
)}
{/* Table */}
<div className="overflow-auto flex-1">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<span className="inline-flex items-center">
<button type="button" onClick={() => toggleSort("eid")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
EID
<span className="text-[10px] ml-0.5">{sortKey === "eid" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</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">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Name
<span className="text-[10px] ml-0.5">{sortKey === "name" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("chapter")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Chapter
<span className="text-[10px] ml-0.5">{sortKey === "chapter" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
</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("bookings")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Bookings
<span className="text-[10px] ml-0.5">{sortKey === "bookings" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
<InfoTooltip content="Number of non-cancelled allocations in the period (current month + next 3 months)." />
</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("utilization")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Utilization
<span className="text-[10px] ml-0.5">{sortKey === "utilization" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
<InfoTooltip
content={
<span>
Booked hours ÷ available hours × 100 for the period.<br />
Available hours = working days × hours from personal schedule.<br />
<span className="text-orange-300">Orange</span> = &gt;85% · <span className="text-red-300">Red</span> = &gt;100%
</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("target")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Target
<span className="text-[10px] ml-0.5">{sortKey === "target" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
<InfoTooltip content="Chargeability target set by management per resource. Not a computed value." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.map((r) => (
<tr key={r.id} className={`hover:bg-gray-50 ${r.isOverbooked ? "bg-amber-50" : ""}`}>
<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 text-gray-700">{r.bookingCount}</td>
<td className="px-3 py-2 text-right">
<span className={`font-semibold ${r.utilizationPercent > 100 ? "text-red-600" : r.utilizationPercent > 85 ? "text-orange-600" : "text-green-700"}`}>
{r.utilizationPercent}%
</span>
</td>
<td className="px-3 py-2 text-right text-gray-500">{r.chargeabilityTarget}%</td>
</tr>
))}
</tbody>
</table>
{list.length === 0 && (
<div className="text-center py-8 text-sm text-gray-400">No resources found.</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
function formatMoney(cents: number): string {
return (cents / 100).toLocaleString("de-DE") + " EUR";
}
function StatCard({ label, value, sub, info }: { label: string; value: string | number; sub?: string; info?: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-gray-500 flex items-center">
{label}
{info && <InfoTooltip content={info} />}
</span>
<span className="text-2xl font-bold text-gray-900">{value}</span>
{sub && <span className="text-xs text-gray-400">{sub}</span>}
</div>
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
const { data, isLoading } = trpc.dashboard.getOverview.useQuery(undefined, {
staleTime: 60_000,
placeholderData: (prev) => prev,
});
if (isLoading || !data) {
return (
<div className="grid grid-cols-2 gap-3 h-full animate-pulse">
{[...Array(4)].map((_, i) => (
<div key={i} className="rounded-xl bg-gray-100 dark:bg-gray-800 p-4 flex flex-col gap-2">
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-7 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-2 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
);
}
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 h-full content-start">
<StatCard
label="Total Resources"
value={data.totalResources}
sub={`${data.activeResources} active`}
info="All resources in the system. Sub-line shows active resources only."
/>
<StatCard
label="Active Projects"
value={data.activeProjects}
sub={`${data.totalProjects} total`}
info="Projects with status ACTIVE. Total includes all statuses (DRAFT, ON_HOLD, COMPLETED, CANCELLED)."
/>
<StatCard
label="Total Allocations"
value={data.totalAllocations}
sub={`${data.activeAllocations} not cancelled`}
info="All allocation records ever created. 'Not cancelled' excludes allocations with status CANCELLED."
/>
<StatCard
label="Budget Utilization"
value={`${data.budgetSummary.avgUtilizationPercent}%`}
sub={`${formatMoney(data.budgetSummary.totalCostCents)} of ${formatMoney(data.budgetSummary.totalBudgetCents)}`}
info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost = resource LCR × booked hours."
/>
</div>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
type SortKey = "eid" | "name" | "chapter" | "score" | "lcr";
export function TopValueWidget({ config }: WidgetProps) {
const limit = (config.limit as number) || 10;
const [sortKey, setSortKey] = useState<SortKey>("score");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir(key === "score" ? "desc" : "asc"); }
}
const { data, isLoading } = trpc.dashboard.getTopValueResources.useQuery(
{ limit },
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
if (isLoading) {
return (
<div className="animate-pulse flex flex-col gap-1 pt-1">
{[...Array(8)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2">
<div className="h-3 w-4 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-5 w-10 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
);
}
const list = data ?? [];
if (list.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-center py-8 text-gray-400 text-sm">
<p>No scores computed yet or you lack access.</p>
<p className="text-xs mt-1">Admins can recompute scores in Settings.</p>
</div>
);
}
const sorted = [...list].sort((a, b) => {
const mult = sortDir === "asc" ? 1 : -1;
switch (sortKey) {
case "eid": return mult * a.eid.localeCompare(b.eid);
case "name": return mult * a.displayName.localeCompare(b.displayName);
case "chapter": return mult * (a.chapter ?? "").localeCompare(b.chapter ?? "");
case "score": return mult * ((a.valueScore ?? 0) - (b.valueScore ?? 0));
case "lcr": return mult * (a.lcrCents - b.lcrCents);
default: return 0;
}
});
function Ind({ k }: { k: SortKey }) {
return sortKey === k
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "▲" : "▼"}</span>
: <span className="text-[10px] ml-0.5 text-gray-300"></span>;
}
return (
<div className="overflow-auto h-full">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 w-8">#</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("eid")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
EID<Ind k="eid" />
</button>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Name<Ind k="name" />
</button>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("chapter")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Chapter<Ind k="chapter" />
</button>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("score")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Score<Ind k="score" />
</button>
<InfoTooltip
content={
<span>
Composite price/quality score 0100.<br />
Weights: Skill Depth 30% · Cost Efficiency 25% · Skill Breadth 15% · Chargeability 15% · Experience 15%.<br />
Recompute in Admin Settings.
</span>
}
width="w-72"
/>
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("lcr")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
LCR ()<Ind k="lcr" />
</button>
<InfoTooltip content="Labour Cost Rate — hourly cost in EUR. Lower LCR = better cost efficiency score." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.map((r, i) => (
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-3 py-2 text-gray-400 font-medium">{i + 1}</td>
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td>
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "—"}</td>
<td className="px-3 py-2 text-right">
<span
className={`inline-block px-2 py-0.5 rounded-full font-semibold ${
(r.valueScore ?? 0) >= 70
? "bg-green-100 text-green-700"
: (r.valueScore ?? 0) >= 40
? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700"
}`}
>
{r.valueScore ?? "—"}
</span>
</td>
<td className="px-3 py-2 text-right text-gray-700">{(r.lcrCents / 100).toFixed(0)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,316 @@
"use client";
import { clsx } from "clsx";
import { DateInput } from "~/components/ui/DateInput.js";
import { FieldType } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
interface Props {
fieldDefs: BlueprintFieldDefinition[];
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
errors?: Record<string, string>;
className?: string;
}
const INPUT_BASE =
"w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors";
const INPUT_NORMAL = "border-gray-300 bg-white text-gray-900";
const INPUT_ERROR = "border-red-400 bg-red-50 text-gray-900";
function inputClass(hasError: boolean) {
return clsx(INPUT_BASE, hasError ? INPUT_ERROR : INPUT_NORMAL);
}
interface FieldInputProps {
fieldDef: BlueprintFieldDefinition;
value: unknown;
onChange: (key: string, value: unknown) => void;
hasError: boolean;
}
function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
const { key, type, placeholder, validation, options } = fieldDef;
switch (type) {
case FieldType.TEXT:
return (
<input
type="text"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder}
maxLength={validation?.maxLength}
minLength={validation?.minLength}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
/>
);
case FieldType.TEXTAREA:
return (
<textarea
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder}
maxLength={validation?.maxLength}
onChange={(e) => onChange(key, e.target.value)}
className={clsx(inputClass(hasError), "resize-y min-h-[80px]")}
rows={3}
/>
);
case FieldType.NUMBER:
return (
<input
type="number"
id={key}
value={value !== undefined && value !== null && value !== "" ? Number(value) : ""}
placeholder={placeholder}
min={validation?.min}
max={validation?.max}
onChange={(e) =>
onChange(key, e.target.value === "" ? "" : Number(e.target.value))
}
className={inputClass(hasError)}
/>
);
case FieldType.BOOLEAN: {
const checked = value === true || value === "true" || value === 1;
return (
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
id={key}
checked={checked}
onChange={(e) => onChange(key, e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">
{checked ? "Yes" : "No"}
</span>
</label>
);
}
case FieldType.DATE:
return (
<DateInput
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
onChange={(v) => onChange(key, v)}
className={inputClass(hasError)}
/>
);
case FieldType.SELECT:
return (
<select
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
>
<option value="">{placeholder ?? "Select…"}</option>
{(options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
case FieldType.MULTI_SELECT: {
const selectedVals = Array.isArray(value) ? value.map(String) : [];
return (
<div className="space-y-1.5">
{(options ?? []).map((opt) => {
const checked = selectedVals.includes(opt.value);
return (
<label key={opt.value} className="inline-flex items-center gap-2 cursor-pointer mr-4">
<input
type="checkbox"
value={opt.value}
checked={checked}
onChange={(e) => {
const next = e.target.checked
? [...selectedVals, opt.value]
: selectedVals.filter((v) => v !== opt.value);
onChange(key, next);
}}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">{opt.label}</span>
</label>
);
})}
</div>
);
}
case FieldType.URL:
return (
<input
type="url"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder ?? "https://"}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
/>
);
case FieldType.EMAIL:
return (
<input
type="email"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder ?? "email@example.com"}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
/>
);
default:
return (
<input
type="text"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
/>
);
}
}
interface FieldWrapperProps {
fieldDef: BlueprintFieldDefinition;
value: unknown;
onChange: (key: string, value: unknown) => void;
error?: string;
}
function FieldWrapper({ fieldDef, value, onChange, error }: FieldWrapperProps) {
const hasError = Boolean(error);
return (
<div className="flex flex-col gap-1">
<label
htmlFor={fieldDef.key}
className="text-sm font-medium text-gray-700"
>
{fieldDef.label}
{fieldDef.required && (
<span className="ml-0.5 text-red-500" aria-hidden="true">
*
</span>
)}
</label>
<FieldInput
fieldDef={fieldDef}
value={value}
onChange={onChange}
hasError={hasError}
/>
{fieldDef.description && !error && (
<p className="text-xs text-gray-400">{fieldDef.description}</p>
)}
{error && (
<p className="text-xs text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}
function FieldGroup({
group,
fields,
values,
onChange,
errors,
}: {
group: string;
fields: BlueprintFieldDefinition[];
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
errors?: Record<string, string>;
}) {
return (
<fieldset className="space-y-4 border border-gray-200 rounded-lg p-4">
<legend className="text-sm font-semibold text-gray-700 px-1">{group}</legend>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{fields.map((field) => (
<FieldWrapper
key={field.id}
fieldDef={field}
value={values[field.key]}
onChange={onChange}
{...(errors?.[field.key] !== undefined ? { error: errors[field.key] } : {})}
/>
))}
</div>
</fieldset>
);
}
export function DynamicFieldEditor({
fieldDefs,
values,
onChange,
errors,
className,
}: Props) {
const sorted = [...fieldDefs].sort((a, b) => a.order - b.order);
const ungrouped = sorted.filter((f) => !f.group);
const groupMap = new Map<string, BlueprintFieldDefinition[]>();
for (const field of sorted) {
if (!field.group) continue;
const existing = groupMap.get(field.group) ?? [];
existing.push(field);
groupMap.set(field.group, existing);
}
if (sorted.length === 0) {
return null;
}
return (
<div className={clsx("space-y-6", className)}>
{ungrouped.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{ungrouped.map((field) => (
<FieldWrapper
key={field.id}
fieldDef={field}
value={values[field.key]}
onChange={onChange}
{...(errors?.[field.key] !== undefined ? { error: errors[field.key] } : {})}
/>
))}
</div>
)}
{[...groupMap.entries()].map(([group, fields]) => (
<FieldGroup
key={group}
group={group}
fields={fields}
values={values}
onChange={onChange}
{...(errors !== undefined ? { errors } : {})}
/>
))}
</div>
);
}
@@ -0,0 +1,152 @@
"use client";
import { clsx } from "clsx";
import { formatDateLong } from "~/lib/format.js";
import { FieldType } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
interface Props {
fieldDefs: BlueprintFieldDefinition[];
values: Record<string, unknown>;
className?: string;
}
function renderValue(fieldDef: BlueprintFieldDefinition, value: unknown): React.ReactNode {
if (value === null || value === undefined || value === "") {
return <span className="text-gray-400"></span>;
}
switch (fieldDef.type) {
case FieldType.TEXT:
case FieldType.TEXTAREA:
case FieldType.URL:
case FieldType.EMAIL:
return <span className="text-gray-900">{String(value)}</span>;
case FieldType.NUMBER:
return (
<span className="text-gray-900">
{typeof value === "number" ? value.toLocaleString() : String(value)}
</span>
);
case FieldType.BOOLEAN: {
const bool = value === true || value === "true" || value === 1;
return (
<span
className={clsx(
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
bool
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-500",
)}
>
{bool ? "Yes" : "No"}
</span>
);
}
case FieldType.DATE: {
const dateStr = String(value);
const parsed = new Date(dateStr);
if (isNaN(parsed.getTime())) {
return <span className="text-gray-900">{dateStr}</span>;
}
return <span className="text-gray-900">{formatDateLong(parsed)}</span>;
}
case FieldType.SELECT: {
const strVal = String(value);
const option = fieldDef.options?.find((o) => o.value === strVal);
return <span className="text-gray-900">{option?.label ?? strVal}</span>;
}
case FieldType.MULTI_SELECT: {
const rawVals = Array.isArray(value) ? value : [value];
const strVals = rawVals.map((v) => String(v)).filter(Boolean);
if (strVals.length === 0) {
return <span className="text-gray-400"></span>;
}
const labels = strVals.map((v) => {
const option = fieldDef.options?.find((o) => o.value === v);
return { value: v, label: option?.label ?? v, color: option?.color };
});
return (
<div className="flex flex-wrap gap-1">
{labels.map(({ value: v, label }) => (
<span
key={v}
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-brand-50 text-brand-700"
>
{label}
</span>
))}
</div>
);
}
default:
return <span className="text-gray-900">{String(value)}</span>;
}
}
function FieldRow({ fieldDef, value }: { fieldDef: BlueprintFieldDefinition; value: unknown }) {
return (
<div className="flex flex-col gap-0.5">
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wider">
{fieldDef.label}
</dt>
<dd className="text-sm">{renderValue(fieldDef, value)}</dd>
{fieldDef.description && (
<p className="text-xs text-gray-400">{fieldDef.description}</p>
)}
</div>
);
}
export function DynamicFieldRenderer({ fieldDefs, values, className }: Props) {
const sorted = [...fieldDefs].sort((a, b) => a.order - b.order);
// Separate grouped and ungrouped fields
const ungrouped = sorted.filter((f) => !f.group);
const groupMap = new Map<string, BlueprintFieldDefinition[]>();
for (const field of sorted) {
if (!field.group) continue;
const existing = groupMap.get(field.group) ?? [];
existing.push(field);
groupMap.set(field.group, existing);
}
if (sorted.length === 0) {
return null;
}
return (
<div className={clsx("space-y-6", className)}>
{ungrouped.length > 0 && (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{ungrouped.map((field) => (
<FieldRow key={field.id} fieldDef={field} value={values[field.key]} />
))}
</dl>
)}
{[...groupMap.entries()].map(([group, fields]) => (
<div key={group} className="space-y-3">
<h4 className="text-sm font-semibold text-gray-700 border-b border-gray-200 pb-1">
{group}
</h4>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{fields.map((field) => (
<FieldRow key={field.id} fieldDef={field} value={values[field.key]} />
))}
</dl>
</div>
))}
</div>
);
}
@@ -0,0 +1,2 @@
export { DynamicFieldRenderer } from "./DynamicFieldRenderer.js";
export { DynamicFieldEditor } from "./DynamicFieldEditor.js";
@@ -0,0 +1,215 @@
"use client";
import { useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
interface ApplyEffortRulesProps {
estimateId: string;
canEdit: boolean;
onApplied?: () => void;
}
export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffortRulesProps) {
const utils = trpc.useUtils();
const { data: ruleSets, isLoading } = trpc.effortRule.list.useQuery();
const [selectedRuleSetId, setSelectedRuleSetId] = useState<string>("");
const [mode, setMode] = useState<"replace" | "append">("replace");
const [showPreview, setShowPreview] = useState(false);
const previewQuery = trpc.effortRule.preview.useQuery(
{ estimateId, ruleSetId: selectedRuleSetId },
{ enabled: showPreview && Boolean(selectedRuleSetId) },
);
const applyMutation = trpc.effortRule.apply.useMutation({
onSuccess: (result) => {
utils.estimate.getById.invalidate();
setShowPreview(false);
onApplied?.();
if (result.warnings.length > 0) {
alert(`Generated ${result.linesGenerated} demand lines.\n\nWarnings:\n${result.warnings.join("\n")}`);
}
},
});
// Auto-select default rule set
if (!selectedRuleSetId && ruleSets) {
const defaultSet = ruleSets.find((rs) => rs.isDefault) ?? ruleSets[0];
if (defaultSet) {
setSelectedRuleSetId(defaultSet.id);
}
}
if (!canEdit) return null;
if (isLoading) {
return <p className="text-sm text-gray-400">Loading effort rules...</p>;
}
if (!ruleSets || ruleSets.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500">
No effort rule sets defined.{" "}
<a href="/admin/effort-rules" className="text-brand-600 hover:underline">
Create one
</a>{" "}
to auto-generate demand lines from scope items.
</div>
);
}
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Generate demand lines from scope</h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Rule set</span>
<select
value={selectedRuleSetId}
onChange={(e) => {
setSelectedRuleSetId(e.target.value);
setShowPreview(false);
}}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
>
{ruleSets.map((rs) => (
<option key={rs.id} value={rs.id}>
{rs.name} ({rs.rules.length} rules){rs.isDefault ? " *" : ""}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Mode</span>
<select
value={mode}
onChange={(e) => setMode(e.target.value as "replace" | "append")}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
>
<option value="replace">Replace existing lines</option>
<option value="append">Append to existing</option>
</select>
</label>
<button
onClick={() => setShowPreview(!showPreview)}
disabled={!selectedRuleSetId}
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{showPreview ? "Hide preview" : "Preview"}
</button>
<button
onClick={() => {
if (!selectedRuleSetId) return;
const action = mode === "replace" ? "replace all existing demand lines" : "append new demand lines";
if (confirm(`This will ${action}. Continue?`)) {
applyMutation.mutate({ estimateId, ruleSetId: selectedRuleSetId, mode });
}
}}
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"
>
{applyMutation.isPending ? "Generating..." : "Apply rules"}
</button>
</div>
{applyMutation.error && (
<p className="mt-2 text-sm text-red-600">{applyMutation.error.message}</p>
)}
{/* Preview */}
{showPreview && previewQuery.data && (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
<span>{previewQuery.data.scopeItemCount} scope items</span>
<span>{previewQuery.data.ruleCount} rules</span>
<span className="font-semibold text-brand-700">{previewQuery.data.lines.length} demand lines would be generated</span>
{previewQuery.data.unmatchedScopeItems.length > 0 && (
<span className="text-amber-600">{previewQuery.data.unmatchedScopeItems.length} unmatched scope items</span>
)}
</div>
{previewQuery.data.warnings.length > 0 && (
<div className="rounded-xl bg-amber-50 p-3 text-sm text-amber-700">
{previewQuery.data.warnings.map((w, i) => (
<p key={i}>{w}</p>
))}
</div>
)}
{/* Aggregated discipline summary */}
{previewQuery.data.aggregated.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Discipline</th>
<th className="px-3 py-2 font-medium">Chapter</th>
<th className="px-3 py-2 text-right font-medium">Total hours</th>
<th className="pl-3 py-2 text-right font-medium">Lines</th>
</tr>
</thead>
<tbody>
{previewQuery.data.aggregated.map((agg, i) => (
<tr key={i} className="border-b border-gray-100">
<td className="py-1.5 pr-3 font-medium text-gray-900">{agg.discipline}</td>
<td className="px-3 py-1.5 text-gray-500">{agg.chapter ?? "\u2014"}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">{agg.totalHours.toFixed(1)} h</td>
<td className="pl-3 py-1.5 text-right tabular-nums text-gray-500">{agg.lineCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Detailed lines (collapsible) */}
{previewQuery.data.lines.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
Show all {previewQuery.data.lines.length} generated lines
</summary>
<div className="mt-2 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Scope item</th>
<th className="px-3 py-2 font-medium">Discipline</th>
<th className="px-3 py-2 font-medium">Chapter</th>
<th className="px-3 py-2 font-medium">Mode</th>
<th className="px-3 py-2 text-right font-medium">Units</th>
<th className="px-3 py-2 text-right font-medium">h/unit</th>
<th className="pl-3 py-2 text-right font-medium">Hours</th>
</tr>
</thead>
<tbody>
{previewQuery.data.lines.map((line, i) => (
<tr key={i} className={clsx("border-b border-gray-100", i % 2 === 0 ? "" : "bg-gray-50")}>
<td className="py-1.5 pr-3 text-gray-900">{line.scopeItemName}</td>
<td className="px-3 py-1.5 text-gray-700">{line.discipline}</td>
<td className="px-3 py-1.5 text-gray-500">{line.chapter ?? "\u2014"}</td>
<td className="px-3 py-1.5 text-gray-500">{line.unitMode}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-600">{line.unitCount}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-600">{line.hoursPerUnit}</td>
<td className="pl-3 py-1.5 text-right tabular-nums font-medium text-gray-900">{line.hours.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</div>
)}
{showPreview && previewQuery.isLoading && (
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
)}
</div>
);
}
@@ -0,0 +1,214 @@
"use client";
import { useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
interface ApplyExperienceMultipliersProps {
estimateId: string;
canEdit: boolean;
onApplied?: () => void;
}
function formatCents(cents: number): string {
return (cents / 100).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: ApplyExperienceMultipliersProps) {
const utils = trpc.useUtils();
const { data: sets, isLoading } = trpc.experienceMultiplier.list.useQuery();
const [selectedSetId, setSelectedSetId] = useState<string>("");
const [showPreview, setShowPreview] = useState(false);
const previewQuery = trpc.experienceMultiplier.preview.useQuery(
{ estimateId, multiplierSetId: selectedSetId },
{ enabled: showPreview && Boolean(selectedSetId) },
);
const applyMutation = trpc.experienceMultiplier.apply.useMutation({
onSuccess: (result) => {
utils.estimate.getById.invalidate();
setShowPreview(false);
onApplied?.();
alert(
`Updated ${result.linesUpdated} demand line(s).\n` +
`Hours: ${result.totalOriginalHours}h -> ${result.totalAdjustedHours}h`,
);
},
});
// Auto-select default set
if (!selectedSetId && sets) {
const defaultSet = sets.find((s) => s.isDefault) ?? sets[0];
if (defaultSet) {
setSelectedSetId(defaultSet.id);
}
}
if (!canEdit) return null;
if (isLoading) {
return <p className="text-sm text-gray-400">Loading experience multipliers...</p>;
}
if (!sets || sets.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500">
No experience multiplier sets defined.{" "}
<a href="/admin/experience-multipliers" className="text-brand-600 hover:underline">
Create one
</a>{" "}
to apply rate and effort adjustments.
</div>
);
}
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Apply experience multipliers</h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Multiplier set</span>
<select
value={selectedSetId}
onChange={(e) => {
setSelectedSetId(e.target.value);
setShowPreview(false);
}}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
>
{sets.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.rules.length} rules){s.isDefault ? " *" : ""}
</option>
))}
</select>
</label>
<button
onClick={() => setShowPreview(!showPreview)}
disabled={!selectedSetId}
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{showPreview ? "Hide preview" : "Preview"}
</button>
<button
onClick={() => {
if (!selectedSetId) return;
if (confirm("This will update cost/bill rates and hours on matching demand lines. Continue?")) {
applyMutation.mutate({ estimateId, multiplierSetId: selectedSetId });
}
}}
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"
>
{applyMutation.isPending ? "Applying..." : "Apply multipliers"}
</button>
</div>
{applyMutation.error && (
<p className="mt-2 text-sm text-red-600">{applyMutation.error.message}</p>
)}
{/* Preview */}
{showPreview && previewQuery.data && (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
<span>{previewQuery.data.demandLineCount} demand lines</span>
<span>{previewQuery.data.ruleCount} rules</span>
<span className="font-semibold text-brand-700">
{previewQuery.data.linesChanged} line(s) would be adjusted
</span>
</div>
{previewQuery.data.linesChanged > 0 && (
<div className="rounded-xl bg-blue-50 p-3 text-sm text-blue-700">
Total cost: {formatCents(previewQuery.data.totalOriginalCostCents)} {"->"}{" "}
{formatCents(previewQuery.data.totalAdjustedCostCents)}
</div>
)}
{/* Per-line preview */}
{previewQuery.data.previews.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
Show all {previewQuery.data.previews.length} lines
</summary>
<div className="mt-2 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Chapter</th>
<th className="px-3 py-2 text-right font-medium">Cost rate</th>
<th className="px-3 py-2 text-right font-medium">Bill rate</th>
<th className="px-3 py-2 text-right font-medium">Hours</th>
<th className="pl-3 py-2 font-medium">Changes</th>
</tr>
</thead>
<tbody>
{previewQuery.data.previews.map((p, i) => (
<tr
key={p.demandLineId}
className={clsx(
"border-b border-gray-100",
p.hasChanges ? "bg-amber-50" : i % 2 === 0 ? "" : "bg-gray-50",
)}
>
<td className="py-1.5 pr-3 text-gray-900">{p.name}</td>
<td className="px-3 py-1.5 text-gray-500">{p.chapter ?? "\u2014"}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
{p.hasChanges && p.adjustedCostRateCents !== p.originalCostRateCents ? (
<>
<span className="line-through text-gray-400">{formatCents(p.originalCostRateCents)}</span>{" "}
{formatCents(p.adjustedCostRateCents)}
</>
) : (
formatCents(p.originalCostRateCents)
)}
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
{p.hasChanges && p.adjustedBillRateCents !== p.originalBillRateCents ? (
<>
<span className="line-through text-gray-400">{formatCents(p.originalBillRateCents)}</span>{" "}
{formatCents(p.adjustedBillRateCents)}
</>
) : (
formatCents(p.originalBillRateCents)
)}
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
{p.hasChanges && p.adjustedHours !== p.originalHours ? (
<>
<span className="line-through text-gray-400">{p.originalHours}h</span>{" "}
{p.adjustedHours}h
</>
) : (
`${p.originalHours}h`
)}
</td>
<td className="pl-3 py-1.5 text-xs text-gray-500">
{p.hasChanges ? p.appliedRules[0] ?? "" : "No change"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</div>
)}
{showPreview && previewQuery.isLoading && (
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
)}
</div>
);
}
@@ -0,0 +1,837 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { EstimateStatus } from "@planarchy/shared";
import { computeEvenSpread } from "@planarchy/engine";
import { isSpreadsheetFile } from "~/lib/excel.js";
import { parseScopeImport } from "~/lib/scopeImportParser.js";
import { clsx } from "clsx";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
import { trpc } from "~/lib/trpc/client.js";
const INPUT_CLS =
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
const SELECT_CLS = INPUT_CLS;
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
const STEP_LABELS = ["Setup", "Assumptions", "Scope", "Staffing", "Review"];
interface AssumptionRow {
id: string;
category: string;
key: string;
label: string;
value: string;
}
interface ScopeRow {
id: string;
sequenceNo: number;
scopeType: string;
name: string;
description: string;
}
interface DemandRow {
id: string;
name: string;
roleId: string | null;
resourceId: string | null;
hours: string;
chapter: string;
costRate: string;
billRate: string;
currency: string;
}
interface ProjectOption {
id: string;
shortCode: string;
name: string;
startDate?: string | Date | null;
endDate?: string | Date | null;
}
interface RoleOption {
id: string;
name: string;
}
interface ResourceOption {
id: string;
eid: string;
displayName: string;
chapter: string | null;
currency: string;
lcrCents: number;
ucrCents: number;
roleId: string | null;
federalState: string | null;
}
function makeAssumption(): AssumptionRow {
return {
id: crypto.randomUUID(),
category: "commercial",
key: "",
label: "",
value: "",
};
}
function makeScope(sequenceNo = 1): ScopeRow {
return {
id: crypto.randomUUID(),
sequenceNo,
scopeType: "SHOT",
name: "",
description: "",
};
}
function makeDemand(): DemandRow {
return {
id: crypto.randomUUID(),
name: "",
roleId: null,
resourceId: null,
hours: "8",
chapter: "",
costRate: "",
billRate: "",
currency: "EUR",
};
}
function toCents(value: string) {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return 0;
}
return Math.round(parsed * 100);
}
function toHours(value: string) {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return 0;
}
return parsed;
}
function formatMoney(cents: number, currency = "EUR") {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(cents / 100);
}
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
export function EstimateWizard({ onClose }: { onClose: () => void }) {
const [step, setStep] = useState(0);
const [name, setName] = useState("");
const [projectId, setProjectId] = useState<string | null>(null);
const [opportunityId, setOpportunityId] = useState("");
const [baseCurrency, setBaseCurrency] = useState("EUR");
const [status, setStatus] = useState<EstimateStatus>(EstimateStatus.DRAFT);
const [versionLabel, setVersionLabel] = useState("Initial");
const [versionNotes, setVersionNotes] = useState("");
const [assumptions, setAssumptions] = useState<AssumptionRow[]>([makeAssumption()]);
const [scopeItems, setScopeItems] = useState<ScopeRow[]>([makeScope(1)]);
const [demandLines, setDemandLines] = useState<DemandRow[]>([makeDemand()]);
const [error, setError] = useState<string | null>(null);
const [scopeImportWarnings, setScopeImportWarnings] = useState<string[]>([]);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils();
const projectsQuery = trpc.project.list.useQuery({ limit: 200 }, { staleTime: 60_000 });
const rolesQuery = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
const resourcesQuery = trpc.resource.list.useQuery(
{ limit: 500, includeRoles: true, isActive: true },
{ staleTime: 60_000 },
);
const createMutation = trpc.estimate.create.useMutation({
onSuccess: async () => {
await utils.estimate.list.invalidate();
onClose();
},
onError: (mutationError) => {
setError(mutationError.message);
},
});
const projectRows = (projectsQuery.data?.projects ?? []) as unknown as ProjectOption[];
const roleRows = (rolesQuery.data ?? []) as unknown as RoleOption[];
const resourceRows = (resourcesQuery.data?.resources ?? []) as unknown as ResourceOption[];
const projects: ProjectOption[] = projectRows.map((project) => ({
id: project.id,
shortCode: project.shortCode,
name: project.name,
}));
const roles: RoleOption[] = roleRows.map((role) => ({
id: role.id,
name: role.name,
}));
const resources: ResourceOption[] = resourceRows.map((resource) => ({
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
chapter: resource.chapter,
currency: resource.currency,
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
roleId: resource.roleId,
federalState: resource.federalState,
}));
const selectedProject = projectId
? projects.find((project) => project.id === projectId) ?? null
: null;
const summary = useMemo(() => {
return demandLines.reduce(
(accumulator, line) => {
const hours = toHours(line.hours);
const costTotalCents = Math.round(hours * toCents(line.costRate));
const priceTotalCents = Math.round(hours * toCents(line.billRate));
return {
totalHours: accumulator.totalHours + hours,
totalCostCents: accumulator.totalCostCents + costTotalCents,
totalPriceCents: accumulator.totalPriceCents + priceTotalCents,
};
},
{ totalHours: 0, totalCostCents: 0, totalPriceCents: 0 },
);
}, [demandLines]);
const marginCents = summary.totalPriceCents - summary.totalCostCents;
const marginPercent = summary.totalPriceCents > 0
? Math.round((marginCents / summary.totalPriceCents) * 100)
: 0;
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
function updateAssumption(id: string, patch: Partial<AssumptionRow>) {
setAssumptions((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
}
function updateScopeItem(id: string, patch: Partial<ScopeRow>) {
setScopeItems((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
}
function updateDemandLine(id: string, patch: Partial<DemandRow>) {
setDemandLines((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
}
function applyResource(resourceId: string | null, demandLineId: string) {
const resource = resourceId
? resources.find((item) => item.id === resourceId) ?? null
: null;
updateDemandLine(demandLineId, {
resourceId,
name: resource?.displayName ?? "",
chapter: resource?.chapter ?? "",
currency: resource?.currency ?? baseCurrency,
costRate: resource ? (resource.lcrCents / 100).toFixed(2) : "",
billRate: resource ? (resource.ucrCents / 100).toFixed(2) : "",
roleId: resource?.roleId ?? null,
});
}
async function handleScopeImport(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
event.target.value = "";
if (!isSpreadsheetFile(file)) {
setScopeImportWarnings(["Unsupported file type. Please upload .xlsx, .xls, or .csv."]);
return;
}
try {
const result = await parseScopeImport(file);
setScopeImportWarnings(result.warnings);
if (result.rows.length > 0) {
const imported: ScopeRow[] = result.rows.map((row) => ({
id: crypto.randomUUID(),
sequenceNo: row.sequenceNo,
scopeType: row.scopeType,
name: row.name,
description: row.description,
}));
setScopeItems((current) => {
const nonEmpty = current.filter((item) => item.name.trim());
return [...nonEmpty, ...imported];
});
}
} catch {
setScopeImportWarnings(["Failed to parse the file. Please check the format."]);
}
}
function validateStep(targetStep: number) {
if (targetStep === 1 && !name.trim()) {
setError("Estimate name is required.");
return false;
}
setError(null);
return true;
}
function goNext() {
const nextStep = Math.min(step + 1, STEP_LABELS.length - 1);
if (!validateStep(nextStep)) return;
setStep(nextStep);
}
function goBack() {
setStep((current) => Math.max(current - 1, 0));
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!name.trim()) {
setError("Estimate name is required.");
setStep(0);
return;
}
const normalizedDemandLines = demandLines
.map((line, index) => {
const resource = line.resourceId
? resources.find((item) => item.id === line.resourceId) ?? null
: null;
const role = line.roleId
? roles.find((item) => item.id === line.roleId) ?? null
: null;
const hours = toHours(line.hours);
const costRateCents = toCents(line.costRate);
const billRateCents = toCents(line.billRate);
const displayName = line.name.trim() || resource?.displayName || role?.name || `Line ${index + 1}`;
return {
resourceId: line.resourceId ?? undefined,
roleId: line.roleId ?? undefined,
lineType: "LABOR",
name: displayName,
chapter: line.chapter || resource?.chapter || undefined,
hours,
days: hours > 0 ? Number((hours / 8).toFixed(2)) : undefined,
rateSource: resource ? "RESOURCE" : role ? "ROLE" : "MANUAL",
costRateCents,
billRateCents,
currency: line.currency || resource?.currency || baseCurrency,
costTotalCents: Math.round(hours * costRateCents),
priceTotalCents: Math.round(hours * billRateCents),
monthlySpread:
selectedProject?.startDate && selectedProject?.endDate && hours > 0
? computeEvenSpread({
totalHours: hours,
startDate: new Date(selectedProject.startDate),
endDate: new Date(selectedProject.endDate),
}).spread
: {},
staffingAttributes: {
linkedResource: resource ? true : false,
linkedRole: role ? true : false,
},
metadata: {},
};
})
.filter((line) => line.hours > 0);
const normalizedScopeItems = scopeItems
.map((item, index) => ({
sequenceNo: index + 1,
scopeType: item.scopeType.trim() || "SHOT",
name: item.name.trim(),
description: item.description.trim() || undefined,
technicalSpec: {},
sortOrder: index,
metadata: {},
}))
.filter((item) => item.name.length > 0);
const normalizedAssumptions = assumptions
.map((assumption, index) => ({
category: assumption.category.trim() || "general",
key: assumption.key.trim() || slugify(assumption.label) || `assumption_${index + 1}`,
label: assumption.label.trim(),
valueType: "text",
value: assumption.value.trim(),
sortOrder: index,
}))
.filter((assumption) => assumption.label.length > 0 && String(assumption.value).length > 0);
const seenResources = new Set<string>();
const resourceSnapshots = normalizedDemandLines.flatMap((line) => {
if (!line.resourceId) return [];
if (seenResources.has(line.resourceId)) return [];
seenResources.add(line.resourceId);
const resource = resources.find((item) => item.id === line.resourceId) ?? null;
if (!resource) return [];
return [
{
resourceId: resource.id,
sourceEid: resource.eid,
displayName: resource.displayName,
chapter: resource.chapter ?? undefined,
roleId: resource.roleId ?? undefined,
currency: resource.currency,
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
location: resource.federalState ?? undefined,
attributes: {},
},
];
});
createMutation.mutate({
projectId: projectId ?? undefined,
name: name.trim(),
opportunityId: opportunityId.trim() || undefined,
baseCurrency,
status,
versionLabel: versionLabel.trim() || undefined,
versionNotes: versionNotes.trim() || undefined,
assumptions: normalizedAssumptions,
scopeItems: normalizedScopeItems,
demandLines: normalizedDemandLines,
resourceSnapshots,
metrics: [],
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-950/45 p-4">
<div ref={panelRef} className="flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-[32px] bg-white shadow-2xl">
<div className="border-b border-gray-100 px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Wizard</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Create a connected estimate</h2>
<p className="mt-1 text-sm text-gray-500">
Rates, resource snapshots, and project linkage are pulled from existing Planarchy data.
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-500 transition hover:border-gray-300 hover:text-gray-700"
>
Close
</button>
</div>
<div className="mt-5 grid gap-2 md:grid-cols-5">
{STEP_LABELS.map((label, index) => (
<button
key={label}
type="button"
onClick={() => {
if (index <= step || validateStep(index)) {
setStep(index);
}
}}
className={clsx(
"rounded-2xl px-4 py-3 text-left transition",
index === step
? "bg-brand-600 text-white"
: index < step
? "bg-brand-50 text-brand-700"
: "bg-gray-50 text-gray-400",
)}
>
<span className="block text-xs uppercase tracking-wide">Step {index + 1}</span>
<span className="mt-1 block text-sm font-semibold">{label}</span>
</button>
))}
</div>
</div>
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="grid min-h-0 flex-1 gap-0 lg:grid-cols-[minmax(0,1.15fr),360px]">
<div className="min-h-0 overflow-y-auto px-6 py-6">
{step === 0 && (
<div className="space-y-5">
<div className="grid gap-5 md:grid-cols-2">
<div>
<label className={LABEL_CLS}>Estimate Name</label>
<input value={name} onChange={(event) => setName(event.target.value)} className={INPUT_CLS} placeholder="CGI Breakdown Q2 2026" />
</div>
<div>
<label className={LABEL_CLS}>Linked Project</label>
<ProjectCombobox value={projectId} onChange={setProjectId} placeholder="Link to project" />
</div>
<div>
<label className={LABEL_CLS}>Opportunity ID</label>
<input value={opportunityId} onChange={(event) => setOpportunityId(event.target.value)} className={INPUT_CLS} placeholder="Optional CRM or sales reference" />
</div>
<div>
<label className={LABEL_CLS}>Estimate Status</label>
<select value={status} onChange={(event) => setStatus(event.target.value as EstimateStatus)} className={SELECT_CLS}>
{Object.values(EstimateStatus).map((value) => (
<option key={value} value={value}>
{value.replace("_", " ")}
</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLS}>Base Currency</label>
<input value={baseCurrency} onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())} className={INPUT_CLS} maxLength={3} />
</div>
<div>
<label className={LABEL_CLS}>Version Label</label>
<input value={versionLabel} onChange={(event) => setVersionLabel(event.target.value)} className={INPUT_CLS} placeholder="Initial" />
</div>
</div>
<div>
<label className={LABEL_CLS}>Version Notes</label>
<textarea
value={versionNotes}
onChange={(event) => setVersionNotes(event.target.value)}
rows={5}
className={INPUT_CLS}
placeholder="Document assumptions, exclusions, or client comments."
/>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 p-5">
<p className="text-sm font-semibold text-gray-900">Live connection preview</p>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Project source</p>
<p className="mt-1 text-sm text-gray-700">
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "Not linked yet"}
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Live catalogs</p>
<p className="mt-1 text-sm text-gray-700">
{roles.length} roles, {resources.length} active resources available
</p>
</div>
</div>
</div>
</div>
)}
{step === 1 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Commercial and delivery assumptions</h3>
<p className="text-sm text-gray-500">These rows replace free-form spreadsheet notes with structured data.</p>
</div>
<button type="button" onClick={() => setAssumptions((current) => [...current, makeAssumption()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Add assumption
</button>
</div>
<div className="space-y-3">
{assumptions.map((row) => (
<div key={row.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[140px,1fr,1fr,1.2fr,auto]">
<input value={row.category} onChange={(event) => updateAssumption(row.id, { category: event.target.value })} className={INPUT_CLS} placeholder="Category" />
<input value={row.label} onChange={(event) => updateAssumption(row.id, { label: event.target.value })} className={INPUT_CLS} placeholder="Label" />
<input value={row.key} onChange={(event) => updateAssumption(row.id, { key: event.target.value })} className={INPUT_CLS} placeholder="Key (optional)" />
<input value={row.value} onChange={(event) => updateAssumption(row.id, { value: event.target.value })} className={INPUT_CLS} placeholder="Value" />
<button type="button" onClick={() => setAssumptions((current) => current.filter((item) => item.id !== row.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
Remove
</button>
</div>
))}
</div>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Scope breakdown</h3>
<p className="text-sm text-gray-500">Create structured work packages that can later evolve into versioned estimate scope.</p>
</div>
<div className="flex gap-2">
<label className="cursor-pointer rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Import XLSX
<input type="file" accept=".xlsx,.xls,.csv" onChange={handleScopeImport} className="hidden" />
</label>
<button type="button" onClick={() => setScopeItems((current) => [...current, makeScope(current.length + 1)])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Add scope row
</button>
</div>
</div>
{scopeImportWarnings.length > 0 && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
{scopeImportWarnings.map((warning, index) => (
<p key={index}>{warning}</p>
))}
</div>
)}
<div className="space-y-3">
{scopeItems.map((item, index) => (
<div key={item.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[90px,120px,1fr,1.2fr,auto]">
<input value={String(index + 1)} readOnly className={clsx(INPUT_CLS, "bg-gray-50 text-gray-500")} />
<input value={item.scopeType} onChange={(event) => updateScopeItem(item.id, { scopeType: event.target.value })} className={INPUT_CLS} placeholder="Type" />
<input value={item.name} onChange={(event) => updateScopeItem(item.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Name" />
<input value={item.description} onChange={(event) => updateScopeItem(item.id, { description: event.target.value })} className={INPUT_CLS} placeholder="Description" />
<button type="button" onClick={() => setScopeItems((current) => current.filter((row) => row.id !== item.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
Remove
</button>
</div>
))}
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Staffing and rate lines</h3>
<p className="text-sm text-gray-500">Selecting a resource pre-fills cost rate, sell rate, chapter, and role from live data.</p>
</div>
<button type="button" onClick={() => setDemandLines((current) => [...current, makeDemand()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Add staffing line
</button>
</div>
<div className="space-y-4">
{demandLines.map((line) => {
const resource = line.resourceId
? resources.find((item) => item.id === line.resourceId) ?? null
: null;
return (
<div key={line.id} className="rounded-3xl border border-gray-100 p-4">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<label className={LABEL_CLS}>Resource</label>
<ResourceCombobox value={line.resourceId} onChange={(resourceId) => applyResource(resourceId, line.id)} placeholder="Search resource" />
</div>
<div>
<label className={LABEL_CLS}>Role</label>
<select value={line.roleId ?? ""} onChange={(event) => updateDemandLine(line.id, { roleId: event.target.value || null })} className={SELECT_CLS}>
<option value="">Unassigned</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLS}>Line Name</label>
<input value={line.name} onChange={(event) => updateDemandLine(line.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Compositing, lighting, PM, ..." />
</div>
<div>
<label className={LABEL_CLS}>Chapter</label>
<input value={line.chapter} onChange={(event) => updateDemandLine(line.id, { chapter: event.target.value })} className={INPUT_CLS} placeholder="Auto-filled from resource when linked" />
</div>
<div>
<label className={LABEL_CLS}>Hours</label>
<input value={line.hours} onChange={(event) => updateDemandLine(line.id, { hours: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
<div>
<label className={LABEL_CLS}>Currency</label>
<input value={line.currency} onChange={(event) => updateDemandLine(line.id, { currency: event.target.value.toUpperCase() })} className={INPUT_CLS} maxLength={3} />
</div>
<div>
<label className={LABEL_CLS}>Cost Rate / h</label>
<input value={line.costRate} onChange={(event) => updateDemandLine(line.id, { costRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
<div>
<label className={LABEL_CLS}>Sell Rate / h</label>
<input value={line.billRate} onChange={(event) => updateDemandLine(line.id, { billRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
<div className="text-sm text-gray-600">
{resource ? `Linked to ${resource.displayName} (${resource.eid})` : "Manual line"}
</div>
<div className="flex flex-wrap gap-4 text-sm">
<span className="font-medium text-gray-700">
Cost {formatMoney(Math.round(toHours(line.hours) * toCents(line.costRate)), line.currency)}
</span>
<span className="font-medium text-gray-700">
Price {formatMoney(Math.round(toHours(line.hours) * toCents(line.billRate)), line.currency)}
</span>
</div>
<button type="button" onClick={() => setDemandLines((current) => current.filter((item) => item.id !== line.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
Remove
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{step === 4 && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900">Review</h3>
<p className="text-sm text-gray-500">The summary metrics below are recalculated from the demand rows and persisted on create.</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{summary.totalHours.toFixed(1)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalCostCents, baseCurrency)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalPriceCents, baseCurrency)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Margin</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(marginCents, baseCurrency)} ({marginPercent}%)
</p>
</div>
</div>
<div className="grid gap-5 lg:grid-cols-2">
<div className="rounded-3xl border border-gray-100 p-5">
<p className="text-sm font-semibold text-gray-900">Estimate envelope</p>
<dl className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex justify-between gap-4">
<dt>Name</dt>
<dd className="text-right text-gray-900">{name || "Untitled"}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Project</dt>
<dd className="text-right text-gray-900">{selectedProject ? `${selectedProject.shortCode}` : "Standalone"}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Status</dt>
<dd className="text-right text-gray-900">{status.replace("_", " ")}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Version</dt>
<dd className="text-right text-gray-900">{versionLabel || "Initial"}</dd>
</div>
</dl>
</div>
<div className="rounded-3xl border border-gray-100 p-5">
<p className="text-sm font-semibold text-gray-900">Connected records</p>
<dl className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex justify-between gap-4">
<dt>Assumptions</dt>
<dd className="text-right text-gray-900">{assumptions.filter((row) => row.label.trim()).length}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Scope items</dt>
<dd className="text-right text-gray-900">{scopeItems.filter((row) => row.name.trim()).length}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Demand lines</dt>
<dd className="text-right text-gray-900">{demandLines.filter((row) => toHours(row.hours) > 0).length}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Resource snapshots</dt>
<dd className="text-right text-gray-900">{new Set(demandLines.map((row) => row.resourceId).filter(Boolean)).size}</dd>
</div>
</dl>
</div>
</div>
</div>
)}
</div>
<aside className="border-t border-gray-100 bg-gray-50 px-6 py-6 lg:border-l lg:border-t-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">Dynamic summary</p>
<div className="mt-4 space-y-3">
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Project link</p>
<p className="mt-1 text-sm text-gray-800">
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "No linked project"}
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Resource-linked demand</p>
<p className="mt-1 text-sm text-gray-800">
{demandLines.filter((line) => line.resourceId).length} of {demandLines.length} rows tied to live resources
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Calculated totals</p>
<p className="mt-1 text-sm text-gray-800">{summary.totalHours.toFixed(1)} h</p>
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalCostCents, baseCurrency)} cost</p>
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalPriceCents, baseCurrency)} price</p>
</div>
</div>
{error && (
<div className="mt-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
</aside>
</div>
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
<button type="button" onClick={step === 0 ? onClose : goBack} className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
{step === 0 ? "Cancel" : "Back"}
</button>
{step < STEP_LABELS.length - 1 ? (
<button type="button" onClick={goNext} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
Next
</button>
) : (
<button type="submit" disabled={createMutation.isPending} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60">
{createMutation.isPending ? "Creating..." : "Create Estimate"}
</button>
)}
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,104 @@
import type {
EstimateDemandLineCalculationMetadata,
EstimateDemandLineMetadata,
EstimateDemandLineRateMode,
} from "@planarchy/shared";
interface ResourceRateSnapshotLike {
lcrCents: number;
ucrCents: number;
currency: string;
}
function parseRateMode(value: unknown): EstimateDemandLineRateMode | undefined {
return value === "resource" || value === "manual" ? value : undefined;
}
export function parseDemandLineMetadata(
metadata: Record<string, unknown> | null | undefined,
): EstimateDemandLineMetadata {
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
return {};
}
return metadata as EstimateDemandLineMetadata;
}
export function resolveDemandLineCalculationMetadata(options: {
resourceSnapshot?: ResourceRateSnapshotLike | null | undefined;
metadata?: Record<string, unknown> | null | undefined;
costRateCents: number;
billRateCents: number;
}): EstimateDemandLineCalculationMetadata {
const resourceSnapshot = options.resourceSnapshot;
const parsedMetadata = parseDemandLineMetadata(options.metadata);
const calculation =
typeof parsedMetadata.calculation === "object" &&
parsedMetadata.calculation !== null
? parsedMetadata.calculation
: undefined;
const costRateMode =
parseRateMode(calculation?.costRateMode) ??
(resourceSnapshot && options.costRateCents === resourceSnapshot.lcrCents
? "resource"
: "manual");
const billRateMode =
parseRateMode(calculation?.billRateMode) ??
(resourceSnapshot && options.billRateCents === resourceSnapshot.ucrCents
? "resource"
: "manual");
return {
costRateMode,
billRateMode,
totalMode: "computed",
liveCostRateCents: resourceSnapshot?.lcrCents ?? null,
liveBillRateCents: resourceSnapshot?.ucrCents ?? null,
liveCurrency: resourceSnapshot?.currency ?? null,
};
}
export function buildDemandLineMetadata(
metadata: Record<string, unknown> | null | undefined,
calculation: EstimateDemandLineCalculationMetadata,
): EstimateDemandLineMetadata {
return {
...parseDemandLineMetadata(metadata),
calculation,
};
}
export function getEffectiveDemandLineValues(options: {
resourceSnapshot?: ResourceRateSnapshotLike | null | undefined;
hours: number;
currency?: string | null;
defaultCurrency: string;
costRateCents: number;
billRateCents: number;
costRateMode: EstimateDemandLineRateMode;
billRateMode: EstimateDemandLineRateMode;
}) {
const effectiveCostRateCents =
options.costRateMode === "resource" && options.resourceSnapshot
? options.resourceSnapshot.lcrCents
: options.costRateCents;
const effectiveBillRateCents =
options.billRateMode === "resource" && options.resourceSnapshot
? options.resourceSnapshot.ucrCents
: options.billRateCents;
const currency =
((options.costRateMode === "resource" || options.billRateMode === "resource") &&
options.resourceSnapshot?.currency
? options.resourceSnapshot.currency
: options.currency) ||
options.resourceSnapshot?.currency ||
options.defaultCurrency;
return {
effectiveCostRateCents,
effectiveBillRateCents,
currency,
costTotalCents: Math.round(options.hours * effectiveCostRateCents),
priceTotalCents: Math.round(options.hours * effectiveBillRateCents),
};
}
@@ -0,0 +1,129 @@
"use client";
import type {
EstimateDemandLineMetadata,
EstimateExportArtifactPayload,
EstimateExportFormat,
EstimateStatus,
EstimateVersionStatus,
} from "@planarchy/shared";
export interface EstimateMetricView {
id: string;
key: string;
label: string;
valueDecimal: number;
valueCents?: number | null;
currency?: string | null;
}
export interface EstimateAssumptionView {
id: string;
category: string;
key: string;
label: string;
valueType: string;
value: unknown;
notes?: string | null;
}
export interface EstimateScopeItemView {
id: string;
sequenceNo: number;
scopeType: string;
packageCode?: string | null;
name: string;
description?: string | null;
frameCount?: number | null;
itemCount?: number | null;
unitMode?: string | null;
}
export interface EstimateDemandLineView {
id: string;
scopeItemId?: string | null;
roleId?: string | null;
resourceId?: string | null;
lineType: string;
name: string;
chapter?: string | null;
rateSource?: string | null;
hours: number;
currency: string;
costRateCents: number;
billRateCents: number;
costTotalCents: number;
priceTotalCents: number;
monthlySpread?: Record<string, number>;
metadata: EstimateDemandLineMetadata;
}
export interface EstimateResourceSnapshotView {
id: string;
resourceId?: string | null;
sourceEid?: string | null;
displayName: string;
chapter?: string | null;
roleId?: string | null;
currency: string;
lcrCents: number;
ucrCents: number;
fte?: number | null;
location?: string | null;
country?: string | null;
level?: string | null;
workType?: string | null;
attributes: Record<string, unknown>;
}
export interface EstimateExportView {
id: string;
fileName: string;
format: EstimateExportFormat;
payload?: EstimateExportArtifactPayload | Record<string, unknown> | null;
createdAt: Date;
}
export interface EstimateVersionView {
id: string;
versionNumber: number;
label?: string | null;
status: EstimateVersionStatus;
notes?: string | null;
lockedAt?: Date | null;
updatedAt: Date;
assumptions: EstimateAssumptionView[];
scopeItems: EstimateScopeItemView[];
demandLines: EstimateDemandLineView[];
resourceSnapshots: EstimateResourceSnapshotView[];
metrics: EstimateMetricView[];
exports: EstimateExportView[];
}
export interface EstimateWorkspaceView {
id: string;
name: string;
status: EstimateStatus;
projectId?: string | null;
opportunityId?: string | null;
baseCurrency: string;
updatedAt: Date;
project?: {
id: string;
name: string;
shortCode: string;
startDate?: string | Date | null;
endDate?: string | Date | null;
} | null;
versions: EstimateVersionView[];
}
export type WorkspaceTab =
| "overview"
| "assumptions"
| "scope"
| "staffing"
| "financials"
| "phasing"
| "versions"
| "exports";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,490 @@
"use client";
import { useMemo, useState } from "react";
import { clsx } from "clsx";
import {
compareEstimateVersions,
type VersionCompareInput,
type ChapterSubtotal,
type ResourceSnapshotDiff,
type ScopeItemDiff,
} from "@planarchy/engine";
import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js";
function formatMoney(cents: number, currency = "EUR") {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(cents / 100);
}
function formatDelta(value: number, formatter: (v: number) => string) {
const prefix = value > 0 ? "+" : "";
return `${prefix}${formatter(value)}`;
}
function formatHoursDelta(delta: number) {
const prefix = delta > 0 ? "+" : "";
return `${prefix}${delta.toFixed(1)} h`;
}
function versionToInput(v: EstimateVersionView): VersionCompareInput {
return {
label: v.label ?? null,
versionNumber: v.versionNumber,
demandLines: v.demandLines.map((l) => ({
id: l.id,
name: l.name,
hours: l.hours,
costRateCents: l.costRateCents,
billRateCents: l.billRateCents,
costTotalCents: l.costTotalCents,
priceTotalCents: l.priceTotalCents,
...(l.chapter !== undefined ? { chapter: l.chapter } : {}),
lineType: l.lineType,
})),
assumptions: v.assumptions.map((a) => ({
key: a.key,
label: a.label,
value: a.value,
})),
scopeItems: v.scopeItems.map((s) => ({
id: s.id,
name: s.name,
sequenceNo: s.sequenceNo,
scopeType: s.scopeType,
...(s.packageCode !== undefined ? { packageCode: s.packageCode } : {}),
...(s.description !== undefined ? { description: s.description } : {}),
...(s.frameCount !== undefined ? { frameCount: s.frameCount } : {}),
...(s.itemCount !== undefined ? { itemCount: s.itemCount } : {}),
})),
resourceSnapshots: v.resourceSnapshots.map((r) => ({
id: r.id,
...(r.resourceId !== undefined ? { resourceId: r.resourceId } : {}),
displayName: r.displayName,
...(r.chapter !== undefined ? { chapter: r.chapter } : {}),
currency: r.currency,
lcrCents: r.lcrCents,
ucrCents: r.ucrCents,
...(r.location !== undefined ? { location: r.location } : {}),
...(r.level !== undefined ? { level: r.level } : {}),
})),
};
}
const STATUS_ROW_STYLES = {
added: "bg-emerald-50",
removed: "bg-red-50",
changed: "bg-amber-50",
unchanged: "",
} as const;
const STATUS_BADGE_STYLES = {
added: "bg-emerald-100 text-emerald-700",
removed: "bg-red-100 text-red-700",
changed: "bg-amber-100 text-amber-700",
unchanged: "bg-gray-100 text-gray-500",
} as const;
export function VersionCompare({ versions }: { versions: EstimateVersionView[] }) {
const sorted = useMemo(
() => [...versions].sort((a, b) => b.versionNumber - a.versionNumber),
[versions],
);
const [aId, setAId] = useState<string>(sorted[1]?.id ?? sorted[0]?.id ?? "");
const [bId, setBId] = useState<string>(sorted[0]?.id ?? "");
const [hideUnchanged, setHideUnchanged] = useState(false);
const versionA = sorted.find((v) => v.id === aId);
const versionB = sorted.find((v) => v.id === bId);
const diff = useMemo(() => {
if (!versionA || !versionB || versionA.id === versionB.id) return null;
return compareEstimateVersions(versionToInput(versionA), versionToInput(versionB));
}, [versionA, versionB]);
if (sorted.length < 2) {
return (
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-8 text-center text-sm text-gray-500">
At least two versions are required to compare.
</div>
);
}
const filteredDemandDiffs = diff
? hideUnchanged
? diff.demandLineDiffs.filter((d) => d.status !== "unchanged")
: diff.demandLineDiffs
: [];
const filteredAssumptionDiffs = diff
? hideUnchanged
? diff.assumptionDiffs.filter((d) => d.status !== "unchanged")
: diff.assumptionDiffs
: [];
const filteredScopeDiffs = diff
? hideUnchanged
? diff.scopeItemDiffs.filter((d) => d.status !== "unchanged")
: diff.scopeItemDiffs
: [];
const filteredResourceDiffs = diff
? hideUnchanged
? diff.resourceSnapshotDiffs.filter((d) => d.status !== "unchanged")
: diff.resourceSnapshotDiffs
: [];
return (
<div className="space-y-6">
{/* Version selectors */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-4 text-base font-semibold text-gray-900">Compare versions</h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Base (A)</span>
<select
value={aId}
onChange={(e) => setAId(e.target.value)}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 focus:border-brand-400 focus:outline-none focus:ring-1 focus:ring-brand-400"
>
{sorted.map((v) => (
<option key={v.id} value={v.id}>
v{v.versionNumber} {v.label ? `\u2014 ${v.label}` : ""} ({v.status})
</option>
))}
</select>
</label>
<span className="pb-2 text-sm text-gray-400">vs</span>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Compare (B)</span>
<select
value={bId}
onChange={(e) => setBId(e.target.value)}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 focus:border-brand-400 focus:outline-none focus:ring-1 focus:ring-brand-400"
>
{sorted.map((v) => (
<option key={v.id} value={v.id}>
v{v.versionNumber} {v.label ? `\u2014 ${v.label}` : ""} ({v.status})
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 pb-2 text-sm text-gray-600">
<input
type="checkbox"
checked={hideUnchanged}
onChange={(e) => setHideUnchanged(e.target.checked)}
className="rounded border-gray-300"
/>
Hide unchanged
</label>
</div>
{aId === bId && (
<p className="mt-3 text-sm text-amber-600">Select two different versions to compare.</p>
)}
</div>
{diff && (
<>
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
<SummaryCard
label="Hours"
value={formatHoursDelta(diff.summary.totalHoursDelta)}
positive={diff.summary.totalHoursDelta <= 0}
/>
<SummaryCard
label="Cost"
value={formatDelta(diff.summary.totalCostDelta, (v) => formatMoney(v))}
positive={diff.summary.totalCostDelta <= 0}
/>
<SummaryCard
label="Price"
value={formatDelta(diff.summary.totalPriceDelta, (v) => formatMoney(v))}
positive={diff.summary.totalPriceDelta >= 0}
/>
<SummaryCard
label="Margin"
value={`${diff.summary.marginPercentB.toFixed(1)}% (${diff.summary.marginPercentDelta >= 0 ? "+" : ""}${diff.summary.marginPercentDelta.toFixed(1)}pp)`}
positive={diff.summary.marginPercentDelta >= 0}
/>
<SummaryCard label="Lines +" value={`+${diff.summary.linesAdded}`} positive />
<SummaryCard label="Lines -" value={`-${diff.summary.linesRemoved}`} positive={diff.summary.linesRemoved === 0} />
<SummaryCard label="Lines ~" value={String(diff.summary.linesChanged)} positive={diff.summary.linesChanged === 0} />
<SummaryCard label="Resources ~" value={String(diff.summary.resourceSnapshotsChanged)} positive={diff.summary.resourceSnapshotsChanged === 0} />
</div>
{/* Demand line diffs */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Demand lines</h3>
{filteredDemandDiffs.length === 0 ? (
<p className="py-4 text-center text-sm text-gray-400">
{hideUnchanged ? "No changes in demand lines." : "No demand lines to compare."}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">Hours (A)</th>
<th className="px-3 py-2 text-right font-medium">Hours (B)</th>
<th className="px-3 py-2 text-right font-medium">Hours delta</th>
<th className="px-3 py-2 text-right font-medium">Cost (A)</th>
<th className="px-3 py-2 text-right font-medium">Cost (B)</th>
<th className="px-3 py-2 text-right font-medium">Cost delta</th>
<th className="px-3 py-2 text-right font-medium">Price (A)</th>
<th className="px-3 py-2 text-right font-medium">Price (B)</th>
<th className="pl-3 py-2 text-right font-medium">Price delta</th>
</tr>
</thead>
<tbody>
{filteredDemandDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.hours.toFixed(1) ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.hours.toFixed(1) ?? "\u2014"}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.hoursDelta))}>
{d.hoursDelta != null ? formatHoursDelta(d.hoursDelta) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a ? formatMoney(d.a.costTotalCents) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b ? formatMoney(d.b.costTotalCents) : "\u2014"}
</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.costDelta))}>
{d.costDelta != null ? formatDelta(d.costDelta, (v) => formatMoney(v)) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a ? formatMoney(d.a.priceTotalCents) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b ? formatMoney(d.b.priceTotalCents) : "\u2014"}
</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(d.priceDelta))}>
{d.priceDelta != null ? formatDelta(d.priceDelta, (v) => formatMoney(v)) : "\u2014"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Assumption diffs */}
{(filteredAssumptionDiffs.length > 0 || !hideUnchanged) && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Assumptions</h3>
{filteredAssumptionDiffs.length === 0 ? (
<p className="py-4 text-center text-sm text-gray-400">
{hideUnchanged ? "No changes in assumptions." : "No assumptions to compare."}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Assumption</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Value (A)</th>
<th className="pl-3 py-2 font-medium">Value (B)</th>
</tr>
</thead>
<tbody>
{filteredAssumptionDiffs.map((d) => (
<tr key={d.key} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<td className="py-2 pr-3 font-medium text-gray-900">{d.label}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-gray-700">{formatAssumptionValue(d.aValue)}</td>
<td className="pl-3 py-2 text-gray-700">{formatAssumptionValue(d.bValue)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Chapter subtotals */}
{diff.chapterSubtotals.length > 0 && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">By chapter</h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Chapter</th>
<th className="px-3 py-2 text-right font-medium">Hours (A)</th>
<th className="px-3 py-2 text-right font-medium">Hours (B)</th>
<th className="px-3 py-2 text-right font-medium">Hours delta</th>
<th className="px-3 py-2 text-right font-medium">Cost (A)</th>
<th className="px-3 py-2 text-right font-medium">Cost (B)</th>
<th className="pl-3 py-2 text-right font-medium">Cost delta</th>
</tr>
</thead>
<tbody>
{diff.chapterSubtotals.map((ch) => (
<tr key={ch.chapter} className={clsx("border-b border-gray-100", ch.costDelta !== 0 ? "bg-amber-50" : "")}>
<td className="py-2 pr-3 font-medium text-gray-900">{ch.chapter}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursA.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursB.toFixed(1)}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(ch.hoursDelta))}>
{formatHoursDelta(ch.hoursDelta)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costA)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costB)}</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(ch.costDelta))}>
{formatDelta(ch.costDelta, (v) => formatMoney(v))}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Scope item diffs */}
{filteredScopeDiffs.length > 0 && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">
Scope items
{(diff.summary.scopeItemsAdded > 0 || diff.summary.scopeItemsRemoved > 0 || diff.summary.scopeItemsChanged > 0) && (
<span className="ml-2 text-sm font-normal text-gray-500">
{diff.summary.scopeItemsAdded > 0 && <span className="text-emerald-600">+{diff.summary.scopeItemsAdded}</span>}
{diff.summary.scopeItemsRemoved > 0 && <span className="ml-2 text-red-600">-{diff.summary.scopeItemsRemoved}</span>}
{diff.summary.scopeItemsChanged > 0 && <span className="ml-2 text-amber-600">~{diff.summary.scopeItemsChanged}</span>}
</span>
)}
</h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Type</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">Frames (A)</th>
<th className="px-3 py-2 text-right font-medium">Frames (B)</th>
<th className="px-3 py-2 text-right font-medium">Items (A)</th>
<th className="pl-3 py-2 text-right font-medium">Items (B)</th>
</tr>
</thead>
<tbody>
{filteredScopeDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
<td className="px-3 py-2 text-gray-600">{d.scopeType}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.frameCount ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.frameCount ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.itemCount ?? "\u2014"}</td>
<td className="pl-3 py-2 text-right tabular-nums text-gray-700">{d.b?.itemCount ?? "\u2014"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Resource snapshot diffs */}
{filteredResourceDiffs.length > 0 && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Resource rates</h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">LCR (A)</th>
<th className="px-3 py-2 text-right font-medium">LCR (B)</th>
<th className="px-3 py-2 text-right font-medium">UCR (A)</th>
<th className="px-3 py-2 text-right font-medium">UCR (B)</th>
<th className="px-3 py-2 font-medium">Location (A)</th>
<th className="pl-3 py-2 font-medium">Location (B)</th>
</tr>
</thead>
<tbody>
{filteredResourceDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<td className="py-2 pr-3 font-medium text-gray-900">{d.displayName}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.lcrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.lcrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.ucrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.ucrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-gray-600">{d.a?.location ?? "\u2014"}</td>
<td className="pl-3 py-2 text-gray-600">{d.b?.location ?? "\u2014"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
);
}
function SummaryCard({
label,
value,
positive,
}: {
label: string;
value: string;
positive: boolean;
}) {
return (
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-4 text-center shadow-sm">
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
<p className={clsx("mt-1 text-lg font-semibold tabular-nums", positive ? "text-emerald-700" : "text-red-700")}>
{value}
</p>
</div>
);
}
function deltaColor(delta: number | undefined): string {
if (delta == null || delta === 0) return "text-gray-400";
return delta > 0 ? "text-red-600" : "text-emerald-600";
}
function formatAssumptionValue(value: unknown): string {
if (value === undefined) return "\u2014";
if (value === null) return "null";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
@@ -0,0 +1,438 @@
"use client";
import { useMemo, useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
interface WeeklyPhasingViewProps {
estimateId: string;
canEdit: boolean;
}
type ViewMode = "by_line" | "by_chapter";
type PhasingPattern = "even" | "front_loaded" | "back_loaded";
function getDefaultDateRange(): { start: string; end: string } {
const now = new Date();
const start = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0);
const end = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, "0")}-${String(endDate.getDate()).padStart(2, "0")}`;
return { start, end };
}
function heatColor(hours: number, maxHours: number): string {
if (hours === 0 || maxHours === 0) return "";
const ratio = Math.min(hours / maxHours, 1);
if (ratio < 0.25) return "bg-blue-50";
if (ratio < 0.5) return "bg-blue-100";
if (ratio < 0.75) return "bg-blue-200";
return "bg-blue-300";
}
export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProps) {
const defaults = getDefaultDateRange();
const [startDate, setStartDate] = useState(defaults.start);
const [endDate, setEndDate] = useState(defaults.end);
const [pattern, setPattern] = useState<PhasingPattern>("even");
const [viewMode, setViewMode] = useState<ViewMode>("by_line");
const utils = trpc.useUtils();
const phasingQuery = trpc.estimate.getWeeklyPhasing.useQuery(
{ estimateId },
{ staleTime: 30_000 },
);
const generateMutation = trpc.estimate.generateWeeklyPhasing.useMutation({
onSuccess: () => {
void utils.estimate.getWeeklyPhasing.invalidate({ estimateId });
void utils.estimate.getById.invalidate({ id: estimateId });
},
});
const data = phasingQuery.data;
// Compute max hours for heat-map coloring
const maxHours = useMemo(() => {
if (!data?.hasPhasing) return 0;
let max = 0;
for (const line of data.lines) {
for (const h of Object.values(line.weeklyHours)) {
if (h > max) max = h;
}
}
return max;
}, [data]);
// Compute column totals
const columnTotals = useMemo(() => {
if (!data?.hasPhasing) return {};
const totals: Record<string, number> = {};
for (const line of data.lines) {
for (const [weekKey, hours] of Object.entries(line.weeklyHours)) {
totals[weekKey] = Math.round(((totals[weekKey] ?? 0) + hours) * 100) / 100;
}
}
return totals;
}, [data]);
// Compute chapter column totals
const chapterColumnTotals = useMemo(() => {
if (!data?.hasPhasing) return {};
const totals: Record<string, number> = {};
for (const chapterHours of Object.values(data.chapterAggregation)) {
for (const [weekKey, hours] of Object.entries(chapterHours)) {
totals[weekKey] = Math.round(((totals[weekKey] ?? 0) + hours) * 100) / 100;
}
}
return totals;
}, [data]);
// Compute max hours for chapter view
const maxChapterHours = useMemo(() => {
if (!data?.hasPhasing) return 0;
let max = 0;
for (const chapterHours of Object.values(data.chapterAggregation)) {
for (const h of Object.values(chapterHours)) {
if (h > max) max = h;
}
}
return max;
}, [data]);
const handleGenerate = () => {
generateMutation.mutate({
estimateId,
startDate,
endDate,
pattern,
});
};
// Use config dates from existing phasing if available
const effectiveStart = data?.hasPhasing ? data.config.startDate : startDate;
const effectiveEnd = data?.hasPhasing ? data.config.endDate : endDate;
return (
<div className="space-y-6">
{/* Header / Controls */}
<div className="rounded-3xl border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold text-gray-900">
Weekly Phasing (4Dispo)
</h3>
{canEdit && (
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Start Date
</label>
<input
type="date"
value={data?.hasPhasing ? effectiveStart : startDate}
onChange={(e) => setStartDate(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
End Date
</label>
<input
type="date"
value={data?.hasPhasing ? effectiveEnd : endDate}
onChange={(e) => setEndDate(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Pattern
</label>
<select
value={pattern}
onChange={(e) => setPattern(e.target.value as PhasingPattern)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="even">Even Distribution</option>
<option value="front_loaded">Front Loaded (60/40)</option>
<option value="back_loaded">Back Loaded (40/60)</option>
</select>
</div>
<button
type="button"
onClick={handleGenerate}
disabled={generateMutation.isPending}
className={clsx(
"rounded-lg px-4 py-2 text-sm font-medium text-white",
generateMutation.isPending
? "cursor-not-allowed bg-gray-400"
: "bg-sky-600 hover:bg-sky-700",
)}
>
{generateMutation.isPending ? "Generating..." : "Generate Phasing"}
</button>
</div>
)}
{generateMutation.isError && (
<p className="mt-2 text-sm text-red-600">
{generateMutation.error.message}
</p>
)}
{generateMutation.isSuccess && (
<p className="mt-2 text-sm text-emerald-600">
Phasing generated for {generateMutation.data.linesUpdated} demand
lines.
</p>
)}
</div>
{/* View toggle */}
{data?.hasPhasing && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setViewMode("by_line")}
className={clsx(
"rounded-lg px-3 py-1.5 text-sm font-medium",
viewMode === "by_line"
? "bg-sky-100 text-sky-700"
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
)}
>
By Line
</button>
<button
type="button"
onClick={() => setViewMode("by_chapter")}
className={clsx(
"rounded-lg px-3 py-1.5 text-sm font-medium",
viewMode === "by_chapter"
? "bg-sky-100 text-sky-700"
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
)}
>
By Chapter
</button>
</div>
)}
{/* Phasing Grid */}
{phasingQuery.isLoading && (
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
Loading phasing data...
</div>
)}
{data && !data.hasPhasing && (
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
No weekly phasing generated yet. Use the controls above to generate a
phasing distribution.
</div>
)}
{data?.hasPhasing && viewMode === "by_line" && (
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
Demand Line
</th>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
return (
<th
key={key}
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
>
{week.label}
</th>
);
})}
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
Total
</th>
</tr>
</thead>
<tbody>
{data.lines.map((line) => {
const lineTotal = Object.values(line.weeklyHours).reduce(
(sum, h) => sum + h,
0,
);
return (
<tr
key={line.id}
className="border-b border-gray-100 hover:bg-gray-50/50"
>
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
<div className="truncate max-w-[200px]" title={line.name}>
{line.name}
</div>
{line.chapter && (
<div className="text-xs text-gray-500">
{line.chapter}
</div>
)}
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const hours = line.weeklyHours[key] ?? 0;
return (
<td
key={key}
className={clsx(
"px-3 py-2 text-right tabular-nums",
heatColor(hours, maxHours),
)}
>
{hours > 0 ? hours.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
{lineTotal.toFixed(1)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
Total
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const total = columnTotals[key] ?? 0;
return (
<td
key={key}
className="px-3 py-3 text-right tabular-nums text-gray-900"
>
{total > 0 ? total.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
{Object.values(columnTotals)
.reduce((sum, h) => sum + h, 0)
.toFixed(1)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
{data?.hasPhasing && viewMode === "by_chapter" && (
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
Chapter
</th>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
return (
<th
key={key}
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
>
{week.label}
</th>
);
})}
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
Total
</th>
</tr>
</thead>
<tbody>
{Object.entries(data.chapterAggregation)
.sort(([a], [b]) => a.localeCompare(b))
.map(([chapter, weeklyHours]) => {
const chapterTotal = Object.values(weeklyHours).reduce(
(sum, h) => sum + h,
0,
);
return (
<tr
key={chapter}
className="border-b border-gray-100 hover:bg-gray-50/50"
>
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
{chapter}
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const hours = weeklyHours[key] ?? 0;
return (
<td
key={key}
className={clsx(
"px-3 py-2 text-right tabular-nums",
heatColor(hours, maxChapterHours),
)}
>
{hours > 0 ? hours.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
{chapterTotal.toFixed(1)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
Total
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const total = chapterColumnTotals[key] ?? 0;
return (
<td
key={key}
className="px-3 py-3 text-right tabular-nums text-gray-900"
>
{total > 0 ? total.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
{Object.values(chapterColumnTotals)
.reduce((sum, h) => sum + h, 0)
.toFixed(1)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
{/* Info about current phasing config */}
{data?.hasPhasing && data.config && (
<div className="rounded-3xl border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-600">
<span className="font-medium">Current phasing:</span>{" "}
{data.config.pattern.replace("_", " ")} distribution from{" "}
{data.config.startDate} to {data.config.endDate} across{" "}
{data.weeks.length} weeks, {data.lines.length} demand lines.
</p>
</div>
)}
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { signOut } from "next-auth/react";
import Link from "next/link";
import type { Route } from "next";
import { usePathname } from "next/navigation";
import { clsx } from "clsx";
import { Suspense, useState } from "react";
import { PreferencesModal } from "./PreferencesModal.js";
import { ThemeProvider } from "./ThemeProvider.js";
import { NotificationBell } from "../notifications/NotificationBell.js";
import { NavProgressBar } from "~/components/ui/NavProgressBar.js";
const allNavItems = [
{ href: "/dashboard", label: "Dashboard", icon: "📊", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/resources", label: "Resources", icon: "👥", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/projects", label: "Projects", icon: "📋", roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/estimates", label: "Estimates", icon: "🧮", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/allocations", label: "Allocations", icon: "📅", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/timeline", label: "Timeline", icon: "🗓️", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/staffing", label: "Staffing", icon: "🎯", roles: ["ADMIN", "MANAGER"] },
{ href: "/vacations", label: "Vacations", icon: "🏖️", roles: ["ADMIN", "MANAGER"] },
{ href: "/vacations/my", label: "My Vacations", icon: "🌴", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/roles", label: "Roles", icon: "🏷️", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/skills", label: "Skills Analytics", icon: "📈", roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/reports/chargeability", label: "Chargeability", icon: "📊", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
];
const adminNavItems = [
{ href: "/admin/blueprints", label: "Blueprints", icon: "🏗️" },
{ href: "/admin/countries", label: "Countries", icon: "🌍" },
{ href: "/admin/org-units", label: "Org Units", icon: "🏢" },
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: "📊" },
{ href: "/admin/clients", label: "Clients", icon: "🏦" },
{ href: "/admin/rate-cards", label: "Rate Cards", icon: "💲" },
{ href: "/admin/effort-rules", label: "Effort Rules", icon: "📐" },
{ href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: "📈" },
{ href: "/admin/management-levels", label: "Mgmt Levels", icon: "📶" },
{ href: "/admin/users", label: "Users", icon: "👤" },
{ href: "/admin/settings", label: "Settings", icon: "⚙️" },
{ href: "/admin/skill-import", label: "Skill Import", icon: "📥" },
];
const managerNavItems = [
{ href: "/admin/vacations", label: "Vacation Mgmt", icon: "🏖️" },
];
function Sidebar({ userRole }: { userRole: string }) {
const pathname = usePathname();
const [prefsOpen, setPrefsOpen] = useState(false);
const visibleNavItems = allNavItems.filter((item) => item.roles.includes(userRole));
const showAdmin = userRole === "ADMIN";
const showManagerSection = userRole === "ADMIN" || userRole === "MANAGER";
return (
<>
<nav className="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0">
{/* Logo */}
<div className="p-6 border-b border-gray-200 dark:border-gray-800">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-50">
Pl<span className="text-brand-600">anarchy</span>
</h1>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">Resource Planning</p>
</div>
{/* Nav links */}
<div className="flex-1 p-4 space-y-1 overflow-y-auto">
{visibleNavItems.map((item) => (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
pathname.startsWith(item.href)
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
)}
>
<span>{item.icon}</span>
{item.label}
</Link>
))}
{showManagerSection && (
<>
<div className="pt-3 pb-1">
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
<span className="px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{showAdmin ? "Admin" : "Management"}
</span>
</div>
</div>
{showAdmin && adminNavItems.map((item) => (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
pathname.startsWith(item.href)
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
)}
>
<span>{item.icon}</span>
{item.label}
</Link>
))}
{managerNavItems.map((item) => (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
pathname.startsWith(item.href)
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
)}
>
<span>{item.icon}</span>
{item.label}
</Link>
))}
</>
)}
</div>
{/* Bottom actions */}
<div className="p-4 border-t border-gray-200 dark:border-gray-800 space-y-1">
<div className="flex items-center gap-2 px-3 py-1">
<NotificationBell />
<span className="text-xs text-gray-400 dark:text-gray-500">Notifications</span>
</div>
<button
type="button"
onClick={() => setPrefsOpen(true)}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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.065 2.572c1.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.572 1.065c-.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.065-2.572c-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={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Preferences
</button>
<button
type="button"
onClick={() => void signOut({ callbackUrl: "/auth/signin" })}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign out
</button>
</div>
</nav>
{prefsOpen && <PreferencesModal onClose={() => setPrefsOpen(false)} />}
</>
);
}
export function AppShell({ children, userRole = "USER" }: { children: React.ReactNode; userRole?: string }) {
return (
<ThemeProvider>
<Suspense>
<NavProgressBar />
</Suspense>
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar userRole={userRole} />
<main className="flex-1 overflow-auto">{children}</main>
</div>
</ThemeProvider>
);
}
@@ -0,0 +1,258 @@
"use client";
import { useTheme } from "~/hooks/useTheme.js";
import type { AccentColor, ThemeMode } from "~/hooks/useTheme.js";
import { useAppPreferences, type HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
import { clsx } from "clsx";
interface PreferencesModalProps {
onClose: () => void;
}
const ACCENT_OPTIONS: { value: AccentColor; label: string; swatch: string }[] = [
{ value: "sky", label: "Sky", swatch: "#0284c7" },
{ value: "indigo", label: "Indigo", swatch: "#4f46e5" },
{ value: "violet", label: "Violet", swatch: "#7c3aed" },
{ value: "emerald", label: "Emerald", swatch: "#059669" },
{ value: "rose", label: "Rose", swatch: "#e11d48" },
{ value: "amber", label: "Amber", swatch: "#d97706" },
];
export function PreferencesModal({ onClose }: PreferencesModalProps) {
const { prefs, setMode, setAccent } = useTheme();
const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme } = useAppPreferences();
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end sm:items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-sm">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Preferences</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
>
×
</button>
</div>
<div className="px-6 py-5 space-y-6">
{/* Theme mode */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Appearance
</label>
<div className="grid grid-cols-2 gap-2">
{(["light", "dark"] as ThemeMode[]).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setMode(mode)}
className={clsx(
"flex items-center justify-center gap-2 px-4 py-3 rounded-xl border-2 text-sm font-medium transition-all",
prefs.mode === mode
? "border-brand-600 bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
)}
>
{mode === "light" ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
Light
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
Dark
</>
)}
</button>
))}
</div>
</div>
{/* Accent color */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Highlight Color
</label>
<div className="grid grid-cols-3 gap-2">
{ACCENT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setAccent(opt.value)}
className={clsx(
"flex items-center gap-2 px-3 py-2.5 rounded-xl border-2 text-xs font-medium transition-all",
prefs.accent === opt.value
? "border-brand-600 bg-brand-50 dark:bg-gray-700"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
)}
>
<span
className="w-4 h-4 rounded-full shrink-0"
style={{
backgroundColor: opt.swatch,
boxShadow: prefs.accent === opt.value ? `0 0 0 2px white, 0 0 0 4px ${opt.swatch}` : "none",
}}
/>
<span className="text-gray-700 dark:text-gray-300">{opt.label}</span>
{prefs.accent === opt.value && (
<svg className="w-3 h-3 ml-auto text-brand-600 shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M20.707 5.293a1 1 0 010 1.414l-11 11a1 1 0 01-1.414 0l-5-5a1 1 0 011.414-1.414L9 15.586 19.293 5.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</button>
))}
</div>
</div>
{/* Timeline defaults */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Timeline
</label>
{/* Display mode */}
<div className="mb-4">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Row display style</p>
<div className="grid grid-cols-3 gap-2">
{([
{ value: "strip", label: "Strips", desc: "Classic Gantt blocks" },
{ value: "bar", label: "Bars", desc: "Daily stacked hours" },
{ value: "heatmap", label: "Heatmap", desc: "Utilisation colours" },
] as const).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setTimelineDisplayMode(opt.value)}
className={clsx(
"flex flex-col items-center gap-1 px-3 py-2.5 rounded-xl border-2 text-xs font-medium transition-all",
appPrefs.timelineDisplayMode === opt.value
? "border-brand-600 bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
)}
>
{/* Miniature icon */}
{opt.value === "strip" ? (
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
<rect x="2" y="4" width="12" height="8" rx="2" fill="currentColor" opacity="0.6" />
<rect x="18" y="4" width="12" height="8" rx="2" fill="currentColor" opacity="0.4" />
</svg>
) : opt.value === "bar" ? (
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
<rect x="2" y="8" width="5" height="8" rx="1" fill="currentColor" opacity="0.6" />
<rect x="9" y="4" width="5" height="12" rx="1" fill="currentColor" opacity="0.5" />
<rect x="16" y="6" width="5" height="10" rx="1" fill="currentColor" opacity="0.7" />
<rect x="23" y="2" width="5" height="14" rx="1" fill="currentColor" opacity="0.4" />
</svg>
) : (
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
<rect x="0" y="0" width="8" height="16" fill="#22c55e" opacity="0.5" />
<rect x="8" y="0" width="8" height="16" fill="#eab308" opacity="0.5" />
<rect x="16" y="0" width="8" height="16" fill="#f97316" opacity="0.5" />
<rect x="24" y="0" width="8" height="16" fill="#ef4444" opacity="0.6" />
<rect x="2" y="4" width="10" height="8" rx="1" fill="white" opacity="0.7" />
<rect x="18" y="4" width="10" height="8" rx="1" fill="white" opacity="0.5" />
</svg>
)}
<span>{opt.label}</span>
<span className="font-normal text-[10px] opacity-70">{opt.desc}</span>
</button>
))}
</div>
</div>
{/* Heatmap color scheme */}
<div className="mb-4">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Heatmap color scale</p>
<div className="grid grid-cols-2 gap-2">
{([
{
value: "green-red" as HeatmapColorScheme,
label: "Green → Red",
stops: ["#22c55e","#84cc16","#facc15","#f97316","#ef4444"],
},
{
value: "blue-orange" as HeatmapColorScheme,
label: "Blue → Orange",
stops: ["#38bdf8","#3b82f6","#fbbf24","#f97316","#ef4444"],
},
{
value: "purple-yellow" as HeatmapColorScheme,
label: "Purple → Yellow",
stops: ["#a78bfa","#8b5cf6","#facc15","#f59e0b","#ef4444"],
},
{
value: "mono" as HeatmapColorScheme,
label: "Monochrome",
stops: ["#9ca3af","#6b7280","#4b5563","#374151","#111827"],
},
]).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setHeatmapColorScheme(opt.value)}
className={clsx(
"flex flex-col gap-1.5 px-2.5 py-2 rounded-xl border-2 text-xs font-medium transition-all text-left",
appPrefs.heatmapColorScheme === opt.value
? "border-brand-600 bg-brand-50 dark:bg-brand-900/30"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300",
)}
>
{/* Gradient swatch */}
<div className="flex rounded overflow-hidden w-full h-3">
{opt.stops.map((c) => (
<div key={c} className="flex-1" style={{ backgroundColor: c }} />
))}
</div>
<span className="text-gray-700 dark:text-gray-300">{opt.label}</span>
</button>
))}
</div>
</div>
<label className="flex items-start gap-3 cursor-pointer">
<div className="relative mt-0.5 flex-shrink-0">
<input
type="checkbox"
checked={appPrefs.hideCompletedProjects}
onChange={(e) => setHideCompletedProjects(e.target.checked)}
className="sr-only peer"
/>
<div className={clsx(
"w-9 h-5 rounded-full transition-colors",
appPrefs.hideCompletedProjects ? "bg-brand-600" : "bg-gray-200 dark:bg-gray-700",
)} />
<div className={clsx(
"absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform",
appPrefs.hideCompletedProjects ? "translate-x-4" : "translate-x-0",
)} />
</div>
<div>
<span className="text-sm text-gray-800 dark:text-gray-200 font-medium leading-tight block">
Hide completed &amp; cancelled projects
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
Can be overridden per session in the timeline filter panel.
</span>
</div>
</label>
</div>
{/* Preview note */}
<p className="text-xs text-gray-400 dark:text-gray-500">
Changes apply instantly and are saved in your browser.
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
/**
* Applies the stored theme to <html> immediately on mount (client only).
* Must be rendered inside the layout BEFORE the page content.
*/
export function ThemeProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
try {
const raw = localStorage.getItem("planarchy_theme");
if (!raw) return;
const prefs = JSON.parse(raw) as { mode?: string; accent?: string };
const html = document.documentElement;
if (prefs.mode === "dark") html.classList.add("dark");
else html.classList.remove("dark");
if (prefs.accent) html.setAttribute("data-accent", prefs.accent);
} catch {}
}, []);
return <>{children}</>;
}
@@ -0,0 +1,160 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
function relativeTime(date: Date): string {
const now = Date.now();
const diff = now - date.getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
return `${months}mo ago`;
}
export function NotificationBell() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils();
const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, {
refetchInterval: 30_000,
});
const { data: notifications = [] } = trpc.notification.list.useQuery(
{ limit: 20 },
{ enabled: open },
);
const markRead = trpc.notification.markRead.useMutation({
onSuccess: () => {
void utils.notification.unreadCount.invalidate();
void utils.notification.list.invalidate();
},
});
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleMarkAllRead() {
markRead.mutate({});
}
function handleMarkOne(id: string) {
markRead.mutate({ id });
}
return (
<div ref={ref} className="relative">
{/* Bell button */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="relative p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
aria-label="Notifications"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{unreadCount > 0 && (
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-red-500 rounded-full leading-none">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{/* Dropdown panel */}
{open && (
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-50">
Notifications
</span>
{unreadCount > 0 && (
<button
type="button"
onClick={handleMarkAllRead}
disabled={markRead.isPending}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline disabled:opacity-50"
>
Mark all read
</button>
)}
</div>
{/* List */}
<div className="max-h-96 overflow-y-auto divide-y divide-gray-50 dark:divide-gray-800">
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
No notifications yet
</div>
) : (
notifications.map((n) => {
const isUnread = n.readAt === null;
return (
<button
key={n.id}
type="button"
onClick={() => {
if (isUnread) handleMarkOne(n.id);
}}
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
isUnread ? "bg-blue-50/60 dark:bg-blue-900/10" : ""
}`}
>
<div className="flex items-start gap-2">
{isUnread && (
<span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
)}
<div className={isUnread ? "" : "ml-4"}>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug">
{n.title}
</p>
{n.body && (
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{n.body}
</p>
)}
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
{relativeTime(new Date(n.createdAt))}
</p>
</div>
</div>
</button>
);
})
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,129 @@
"use client";
import { clsx } from "clsx";
interface Warning {
level: string;
message: string;
}
interface BudgetStatusBarProps {
budgetCents: number;
allocatedCents: number;
confirmedCents: number;
proposedCents: number;
warnings: Warning[];
className?: string;
}
function formatEur(cents: number): string {
return (cents / 100).toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
function getConfirmedBarColor(utilizationPercent: number): string {
if (utilizationPercent > 95) return "bg-red-600";
if (utilizationPercent > 85) return "bg-orange-600";
if (utilizationPercent > 70) return "bg-yellow-600";
return "bg-green-600";
}
function getProposedBarColor(utilizationPercent: number): string {
if (utilizationPercent > 95) return "bg-red-300";
if (utilizationPercent > 85) return "bg-orange-300";
if (utilizationPercent > 70) return "bg-yellow-300";
return "bg-green-300";
}
function getWarningBadgeStyle(level: string): string {
if (level === "critical") return "bg-red-100 text-red-700 border border-red-200";
if (level === "warning") return "bg-orange-100 text-orange-700 border border-orange-200";
return "bg-yellow-100 text-yellow-700 border border-yellow-200";
}
export function BudgetStatusBar({
budgetCents,
allocatedCents,
confirmedCents,
proposedCents,
warnings,
className,
}: BudgetStatusBarProps) {
const utilizationPercent = budgetCents > 0 ? (allocatedCents / budgetCents) * 100 : 0;
const confirmedPercent = budgetCents > 0 ? (confirmedCents / budgetCents) * 100 : 0;
const proposedPercent = budgetCents > 0 ? (proposedCents / budgetCents) * 100 : 0;
const remainingCents = budgetCents - allocatedCents;
// Cap visual bar segments at 100% total
const cappedConfirmedPercent = Math.min(confirmedPercent, 100);
const cappedProposedPercent = Math.min(proposedPercent, Math.max(0, 100 - cappedConfirmedPercent));
const highestWarning = warnings.length > 0
? warnings.reduce((prev, curr) => {
const levels: Record<string, number> = { info: 0, warning: 1, critical: 2 };
return (levels[curr.level] ?? 0) > (levels[prev.level] ?? 0) ? curr : prev;
})
: null;
return (
<div className={clsx("space-y-1.5", className)}>
{/* Progress bar with stacked segments */}
<div className="relative h-3 bg-gray-100 rounded-full overflow-hidden">
{/* Confirmed segment */}
<div
className={clsx("absolute left-0 top-0 h-full transition-all", getConfirmedBarColor(utilizationPercent))}
style={{ width: `${cappedConfirmedPercent}%` }}
/>
{/* Proposed segment */}
<div
className={clsx("absolute top-0 h-full transition-all", getProposedBarColor(utilizationPercent))}
style={{ left: `${cappedConfirmedPercent}%`, width: `${cappedProposedPercent}%` }}
/>
</div>
{/* Labels row */}
<div className="flex items-center justify-between text-xs text-gray-600">
<span>
<span className="font-medium">{formatEur(allocatedCents)}</span>
{" / "}
<span>{formatEur(budgetCents)}</span>
{" "}
<span className="text-gray-400">({utilizationPercent.toFixed(1)}%)</span>
</span>
<div className="flex items-center gap-2">
{highestWarning && (
<span
className={clsx(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium",
getWarningBadgeStyle(highestWarning.level),
)}
>
{highestWarning.level === "critical" ? "⚠" : highestWarning.level === "warning" ? "!" : "i"}
{warnings.length > 1 ? `${warnings.length} warnings` : "Warning"}
</span>
)}
<span className={clsx("font-medium", remainingCents < 0 ? "text-red-600" : "text-gray-700")}>
{remainingCents >= 0 ? `${formatEur(remainingCents)} left` : `${formatEur(Math.abs(remainingCents))} over`}
</span>
</div>
</div>
{/* Legend */}
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1">
<span className={clsx("inline-block w-2.5 h-2.5 rounded-sm", getConfirmedBarColor(utilizationPercent))} />
Confirmed {formatEur(confirmedCents)}
</span>
<span className="flex items-center gap-1">
<span className={clsx("inline-block w-2.5 h-2.5 rounded-sm", getProposedBarColor(utilizationPercent))} />
Proposed {formatEur(proposedCents)}
</span>
</div>
</div>
);
}
@@ -0,0 +1,158 @@
"use client";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
import { BudgetStatusBar } from "./BudgetStatusBar.js";
interface BudgetStatusCardProps {
projectId: string;
}
function formatEur(cents: number): string {
return (cents / 100).toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
function WarningIcon({ level }: { level: string }) {
if (level === "critical") {
return (
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
);
}
if (level === "warning") {
return (
<svg className="w-4 h-4 text-orange-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
);
}
return (
<svg className="w-4 h-4 text-blue-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
);
}
function getWarningRowStyle(level: string): string {
if (level === "critical") return "bg-red-50 dark:bg-red-900/30 border border-red-100 dark:border-red-800 text-red-800 dark:text-red-400";
if (level === "warning") return "bg-orange-50 dark:bg-orange-900/30 border border-orange-100 dark:border-orange-800 text-orange-800 dark:text-orange-400";
return "bg-blue-50 dark:bg-blue-900/30 border border-blue-100 dark:border-blue-800 text-blue-800 dark:text-blue-400";
}
export function BudgetStatusCard({ projectId }: BudgetStatusCardProps) {
const { data, isLoading, error } = trpc.timeline.getBudgetStatus.useQuery({ projectId });
if (isLoading) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 animate-pulse">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-4" />
<div className="h-3 bg-gray-100 dark:bg-gray-700 rounded-full mb-3" />
<div className="grid grid-cols-4 gap-3">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-14 bg-gray-100 dark:bg-gray-700 rounded-lg" />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-red-200 dark:border-red-800 p-6">
<p className="text-sm text-red-600 dark:text-red-400">Failed to load budget status: {error.message}</p>
</div>
);
}
if (!data) return null;
const {
budgetCents,
allocatedCents,
confirmedCents,
proposedCents,
remainingCents,
winProbabilityWeightedCents,
warnings,
} = data;
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">Budget Status</h3>
{/* Progress bar */}
<BudgetStatusBar
budgetCents={budgetCents}
allocatedCents={allocatedCents}
confirmedCents={confirmedCents}
proposedCents={proposedCents}
warnings={warnings}
/>
{/* Numeric details grid */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Total Budget</p>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{formatEur(budgetCents)}</p>
</div>
<div className="bg-green-50 dark:bg-green-900/30 rounded-lg p-3">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Confirmed</p>
<p className="text-sm font-semibold text-green-800 dark:text-green-400">{formatEur(confirmedCents)}</p>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/30 rounded-lg p-3">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Proposed</p>
<p className="text-sm font-semibold text-yellow-800 dark:text-yellow-400">{formatEur(proposedCents)}</p>
</div>
<div className={clsx("rounded-lg p-3", remainingCents < 0 ? "bg-red-50 dark:bg-red-900/30" : "bg-blue-50 dark:bg-blue-900/30")}>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Remaining</p>
<p className={clsx("text-sm font-semibold", remainingCents < 0 ? "text-red-800 dark:text-red-400" : "text-blue-800 dark:text-blue-400")}>
{formatEur(remainingCents)}
</p>
</div>
</div>
{/* Win-probability weighted amount */}
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 border-t border-gray-100 dark:border-gray-700 pt-3">
<span className="text-gray-400 dark:text-gray-500">Win-probability weighted cost:</span>
<span className="font-medium text-gray-800 dark:text-gray-100">{formatEur(winProbabilityWeightedCents)}</span>
</div>
{/* Warnings list */}
{warnings.length > 0 && (
<div className="space-y-2 border-t border-gray-100 dark:border-gray-700 pt-3">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Warnings</p>
{warnings.map((warning, idx) => (
<div
key={idx}
className={clsx(
"flex items-start gap-2 rounded-lg px-3 py-2 text-sm",
getWarningRowStyle(warning.level),
)}
>
<WarningIcon level={warning.level} />
<span>{warning.message}</span>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,544 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { OrderType, AllocationType, ProjectStatus } from "@planarchy/shared";
import type { Project } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
const ORDER_TYPE_OPTIONS = [
{ value: "BD", label: "BD" },
{ value: "CHARGEABLE", label: "Chargeable" },
{ value: "INTERNAL", label: "Internal" },
{ value: "OVERHEAD", label: "Overhead" },
] as const;
const ALLOCATION_TYPE_OPTIONS = [
{ value: "INT", label: "INT" },
{ value: "EXT", label: "EXT" },
] as const;
const STATUS_OPTIONS = [
{ value: "DRAFT", label: "Draft" },
{ value: "ACTIVE", label: "Active" },
{ value: "ON_HOLD", label: "On Hold" },
{ value: "COMPLETED", label: "Completed" },
{ value: "CANCELLED", label: "Cancelled" },
] as const;
function formatDateForInput(date: Date | string): string {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
interface FormState {
shortCode: string;
name: string;
orderType: string;
allocationType: string;
winProbability: string;
budgetEur: string;
startDate: string;
endDate: string;
status: string;
responsiblePerson: string;
utilizationCategoryId: string;
clientId: string;
}
function getDefaultForm(): FormState {
const today = formatDateForInput(new Date());
return {
shortCode: "",
name: "",
orderType: "CHARGEABLE",
allocationType: "INT",
winProbability: "100",
budgetEur: "",
startDate: today,
endDate: today,
status: "DRAFT",
responsiblePerson: "",
utilizationCategoryId: "",
clientId: "",
};
}
function projectToForm(project: Project): FormState {
return {
shortCode: project.shortCode,
name: project.name,
orderType: project.orderType,
allocationType: project.allocationType,
winProbability: String(project.winProbability),
budgetEur: String(Math.round(project.budgetCents) / 100),
startDate: formatDateForInput(project.startDate),
endDate: formatDateForInput(project.endDate),
status: project.status,
responsiblePerson: project.responsiblePerson ?? "",
utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
clientId: (project as unknown as { clientId?: string | null }).clientId ?? "",
};
}
interface ProjectModalProps {
project?: Project | null;
onClose: () => void;
}
export function ProjectModal({ project, onClose }: ProjectModalProps) {
const isEdit = !!project;
const utils = trpc.useUtils();
const [form, setForm] = useState<FormState>(() =>
project ? projectToForm(project) : getDefaultForm(),
);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const { data: utilizationCategories } = trpc.utilizationCategory.list.useQuery(undefined, { staleTime: 60_000 });
const { data: clientList } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
// @ts-ignore TS2589: tRPC infers union type too deeply for CreateProjectSchema with .refine()
const createMutation = trpc.project.create.useMutation({
onSuccess: async () => {
await utils.project.list.invalidate();
onClose();
},
onError: (err) => {
setServerError(err.message);
},
});
const updateMutation = trpc.project.update.useMutation({
onSuccess: async () => {
await utils.project.list.invalidate();
onClose();
},
onError: (err) => {
setServerError(err.message);
},
});
const isLoading = createMutation.isPending || updateMutation.isPending;
// Close on Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
setErrors((prev) => ({ ...prev, [key]: undefined }));
setServerError(null);
}
function validate(): boolean {
const newErrors: Partial<Record<keyof FormState, string>> = {};
if (!isEdit && !form.shortCode.trim()) {
newErrors.shortCode = "Short code is required.";
} else if (!isEdit && !/^[A-Z0-9_-]+$/.test(form.shortCode.trim())) {
newErrors.shortCode = "Must be uppercase alphanumeric (A-Z, 0-9, _, -).";
}
if (!form.name.trim()) {
newErrors.name = "Name is required.";
}
const winProb = Number(form.winProbability);
if (isNaN(winProb) || winProb < 0 || winProb > 100) {
newErrors.winProbability = "Must be between 0 and 100.";
}
const budget = parseFloat(form.budgetEur);
if (isNaN(budget) || budget < 0) {
newErrors.budgetEur = "Must be a positive number.";
}
if (!form.startDate) {
newErrors.startDate = "Start date is required.";
}
if (!form.endDate) {
newErrors.endDate = "End date is required.";
} else if (form.startDate && form.endDate < form.startDate) {
newErrors.endDate = "End date must be on or after start date.";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
const budgetCents = Math.round(parseFloat(form.budgetEur) * 100);
const winProbability = Number(form.winProbability);
if (isEdit && project) {
updateMutation.mutate({
id: project.id,
data: {
name: form.name.trim(),
orderType: form.orderType as unknown as OrderType,
allocationType: form.allocationType as unknown as AllocationType,
winProbability,
budgetCents,
startDate: new Date(form.startDate),
endDate: new Date(form.endDate),
status: form.status as unknown as ProjectStatus,
responsiblePerson: form.responsiblePerson.trim() || undefined,
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
...(form.clientId ? { clientId: form.clientId } : {}),
},
});
} else {
createMutation.mutate({
shortCode: form.shortCode.trim(),
name: form.name.trim(),
orderType: form.orderType as unknown as OrderType,
allocationType: form.allocationType as unknown as AllocationType,
winProbability,
budgetCents,
startDate: new Date(form.startDate),
endDate: new Date(form.endDate),
status: form.status as unknown as ProjectStatus,
staffingReqs: [],
dynamicFields: {},
responsiblePerson: form.responsiblePerson.trim() || undefined,
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
...(form.clientId ? { clientId: form.clientId } : {}),
});
}
}
const inputClass =
"w-full 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 dark:bg-gray-900 dark:text-gray-100";
const inputErrorClass =
"w-full px-3 py-2 border border-red-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-400 text-sm dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-xl mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{/* Header */}
<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">
{isEdit ? "Edit Project" : "New Project"}
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} noValidate>
<div className="px-6 py-5 space-y-6">
{serverError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg px-4 py-3 text-sm text-red-700 dark:text-red-400">
{serverError}
</div>
)}
{/* Section 1: Identity */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Identity
</legend>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass} htmlFor="shortCode">
Chargecode <span className="text-red-500">*</span>
</label>
<input
id="shortCode"
type="text"
value={form.shortCode}
onChange={(e) => setField("shortCode", e.target.value.toUpperCase())}
disabled={isEdit}
placeholder="PRJ-001"
className={
isEdit
? `${inputClass} bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed`
: errors.shortCode
? inputErrorClass
: inputClass
}
/>
{errors.shortCode && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.shortCode}</p>
)}
</div>
<div>
<label className={labelClass} htmlFor="name">
Name <span className="text-red-500">*</span>
</label>
<input
id="name"
type="text"
value={form.name}
onChange={(e) => setField("name", e.target.value)}
placeholder="Project name"
className={errors.name ? inputErrorClass : inputClass}
/>
{errors.name && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.name}</p>
)}
</div>
</div>
</fieldset>
{/* Section 2: Classification */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Classification
</legend>
<div className="grid grid-cols-3 gap-4">
<div>
<label className={labelClass} htmlFor="orderType">
Order Type
</label>
<select
id="orderType"
value={form.orderType}
onChange={(e) => setField("orderType", e.target.value)}
className={inputClass}
>
{ORDER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="allocationType">
Allocation
</label>
<select
id="allocationType"
value={form.allocationType}
onChange={(e) => setField("allocationType", e.target.value)}
className={inputClass}
>
{ALLOCATION_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="winProbability">
Win Probability %
</label>
<input
id="winProbability"
type="number"
min={0}
max={100}
value={form.winProbability}
onChange={(e) => setField("winProbability", e.target.value)}
className={errors.winProbability ? inputErrorClass : inputClass}
/>
{errors.winProbability && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.winProbability}</p>
)}
</div>
</div>
</fieldset>
{/* Section: Categorization */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Categorization
</legend>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass} htmlFor="utilizationCategoryId">
Utilization Category
</label>
<select
id="utilizationCategoryId"
value={form.utilizationCategoryId}
onChange={(e) => setField("utilizationCategoryId", e.target.value)}
className={inputClass}
>
<option value=""> Not specified </option>
{(utilizationCategories ?? []).map((cat) => (
<option key={cat.id} value={cat.id}>
{(cat as unknown as { code: string }).code} {(cat as unknown as { name: string }).name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="clientId">
Client
</label>
<select
id="clientId"
value={form.clientId}
onChange={(e) => setField("clientId", e.target.value)}
className={inputClass}
>
<option value=""> Not specified </option>
{(clientList ?? []).map((c) => (
<option key={c.id} value={c.id}>
{(c as unknown as { name: string }).name}
{(c as unknown as { code: string | null }).code ? ` [${(c as unknown as { code: string }).code}]` : ""}
</option>
))}
</select>
</div>
</div>
</fieldset>
{/* Section 3: Timeline & Budget */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Timeline &amp; Budget
</legend>
<div className="grid grid-cols-3 gap-4">
<div>
<label className={labelClass} htmlFor="startDate">
Start Date
</label>
<DateInput
id="startDate"
value={form.startDate}
onChange={(v) => setField("startDate", v)}
className={errors.startDate ? inputErrorClass : inputClass}
/>
{errors.startDate && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.startDate}</p>
)}
</div>
<div>
<label className={labelClass} htmlFor="endDate">
End Date
</label>
<DateInput
id="endDate"
value={form.endDate}
min={form.startDate}
onChange={(v) => setField("endDate", v)}
className={errors.endDate ? inputErrorClass : inputClass}
/>
{errors.endDate && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.endDate}</p>
)}
</div>
<div>
<label className={labelClass} htmlFor="budgetEur">
Budget ()
</label>
<input
id="budgetEur"
type="number"
min={0}
step="0.01"
value={form.budgetEur}
onChange={(e) => setField("budgetEur", e.target.value)}
placeholder="0.00"
className={errors.budgetEur ? inputErrorClass : inputClass}
/>
{errors.budgetEur && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.budgetEur}</p>
)}
</div>
</div>
</fieldset>
{/* Section 4: Status */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Status
</legend>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass} htmlFor="status">
Status
</label>
<select
id="status"
value={form.status}
onChange={(e) => setField("status", e.target.value)}
className={inputClass}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="responsiblePerson">
Responsible Person
</label>
<input
id="responsiblePerson"
type="text"
value={form.responsiblePerson}
onChange={(e) => setField("responsiblePerson", e.target.value)}
placeholder="Name or EID"
className={inputClass}
/>
</div>
</div>
</fieldset>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-xl">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 transition-colors"
>
{isLoading ? (isEdit ? "Saving…" : "Creating…") : isEdit ? "Save Changes" : "Create Project"}
</button>
</div>
</form>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
// @react-pdf/renderer runs server-side only — no "use client" directive
import { Document, Page, StyleSheet, Text, View } from "@react-pdf/renderer";
const styles = StyleSheet.create({
page: { padding: 30, fontFamily: "Helvetica", fontSize: 10 },
title: { fontSize: 18, marginBottom: 4, fontFamily: "Helvetica-Bold" },
subtitle: { fontSize: 11, color: "#6b7280", marginBottom: 20 },
table: { marginTop: 10 },
tableHeader: { flexDirection: "row", backgroundColor: "#f3f4f6", padding: "6 8", borderBottom: "1 solid #e5e7eb" },
tableRow: { flexDirection: "row", padding: "5 8", borderBottom: "1 solid #f3f4f6" },
col1: { width: "25%" },
col2: { width: "20%" },
col3: { width: "15%" },
col4: { width: "15%" },
col5: { width: "15%" },
col6: { width: "10%" },
headerText: { fontFamily: "Helvetica-Bold", color: "#374151", fontSize: 9 },
cellText: { color: "#4b5563", fontSize: 9 },
footer: { position: "absolute", bottom: 20, left: 30, right: 30, textAlign: "center", color: "#9ca3af", fontSize: 8 },
});
interface AllocationRow {
resourceName: string;
projectName: string;
role?: string | null;
startDate: string;
endDate: string;
hoursPerDay: number;
dailyCostCents: number;
}
interface AllocationReportProps {
title: string;
generatedAt: string;
rows: AllocationRow[];
}
export function AllocationReport({ title, generatedAt, rows }: AllocationReportProps) {
return (
<Document>
<Page size="A4" orientation="landscape" style={styles.page}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.subtitle}>Generated: {generatedAt}</Text>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.col1, styles.headerText]}>Resource</Text>
<Text style={[styles.col2, styles.headerText]}>Project</Text>
<Text style={[styles.col3, styles.headerText]}>Role</Text>
<Text style={[styles.col4, styles.headerText]}>Start</Text>
<Text style={[styles.col5, styles.headerText]}>End</Text>
<Text style={[styles.col6, styles.headerText]}>h/day</Text>
</View>
{rows.map((row, i) => (
<View key={i} style={[styles.tableRow, i % 2 === 1 ? { backgroundColor: "#f9fafb" } : {}]}>
<Text style={[styles.col1, styles.cellText]}>{row.resourceName}</Text>
<Text style={[styles.col2, styles.cellText]}>{row.projectName}</Text>
<Text style={[styles.col3, styles.cellText]}>{row.role ?? "—"}</Text>
<Text style={[styles.col4, styles.cellText]}>{row.startDate}</Text>
<Text style={[styles.col5, styles.cellText]}>{row.endDate}</Text>
<Text style={[styles.col6, styles.cellText]}>{row.hoursPerDay}h</Text>
</View>
))}
</View>
<Text style={styles.footer}>Planarchy · Confidential · {rows.length} allocations</Text>
</Page>
</Document>
);
}
@@ -0,0 +1,539 @@
"use client";
import React, { useState, useMemo, useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatMonth(key: string): string {
const [y, m] = key.split("-");
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return `${months[Number(m) - 1]} ${y}`;
}
function pct(ratio: number): string {
return `${Math.round(ratio * 100)}%`;
}
function chgColor(ratio: number, target: number): string {
if (ratio >= target) return "text-green-700 dark:text-green-400";
if (ratio >= target - 0.1) return "text-yellow-700 dark:text-yellow-400";
return "text-red-700 dark:text-red-400";
}
function barStyle(ratio: number, color: string): React.CSSProperties {
return {
background: `linear-gradient(to right, ${color} ${Math.min(ratio * 100, 100)}%, transparent ${Math.min(ratio * 100, 100)}%)`,
};
}
type GroupByField = "none" | "orgUnit" | "mgmtGroup" | "country";
interface ResourceRow {
id: string;
eid: string;
displayName: string;
fte: number;
country: string | null;
city: string | null;
orgUnit: string | null;
mgmtGroup: string | null;
mgmtLevel: string | null;
targetPct: number;
months: MonthData[];
}
interface MonthData {
monthKey: string;
sah: number;
chg: number;
bd: number;
mdi: number;
mo: number;
pdr: number;
absence: number;
unassigned: number;
}
interface GroupSummary {
label: string;
resources: ResourceRow[];
monthTotals: { monthKey: string; chg: number; target: number; gap: number; totalFte: number }[];
}
function computeGroupMonthTotals(
resources: ResourceRow[],
monthKeys: string[],
): GroupSummary["monthTotals"] {
const totalFte = resources.reduce((sum, r) => sum + r.fte, 0);
return monthKeys.map((key, idx) => {
if (totalFte === 0) return { monthKey: key, chg: 0, target: 0, gap: 0, totalFte: 0 };
const chg = resources.reduce((sum, r) => sum + r.fte * r.months[idx]!.chg, 0) / totalFte;
const target = resources.reduce((sum, r) => sum + r.fte * r.targetPct, 0) / totalFte;
return { monthKey: key, chg, target, gap: chg - target, totalFte };
});
}
// ─── Export ──────────────────────────────────────────────────────────────────
async function exportToExcel(
resources: ResourceRow[],
monthKeys: string[],
groupTotals: { monthKey: string; chg: number; target: number; gap: number; totalFte: number }[],
groups: GroupSummary[],
groupBy: GroupByField,
) {
const XLSX = await import("xlsx");
const wb = XLSX.utils.book_new();
// Build main data rows
const headers = ["Name", "EID", "FTE", "Target", "Country", "City", "Org Unit", "Mgmt Group", "Mgmt Level"];
for (const key of monthKeys) {
headers.push(`Chg ${formatMonth(key)}`);
headers.push(`BD ${formatMonth(key)}`);
headers.push(`MD&I ${formatMonth(key)}`);
headers.push(`M&O ${formatMonth(key)}`);
headers.push(`PD&R ${formatMonth(key)}`);
headers.push(`Abs ${formatMonth(key)}`);
headers.push(`Free ${formatMonth(key)}`);
headers.push(`SAH ${formatMonth(key)}`);
}
const rows: (string | number)[][] = [headers];
// Group total row
const totalRow: (string | number)[] = [
"GROUP TOTAL", "", groupTotals[0]?.totalFte ?? 0, groupTotals[0]?.target ?? 0,
"", "", "", "", "",
];
for (const gt of groupTotals) {
totalRow.push(Math.round(gt.chg * 100) / 100, 0, 0, 0, 0, 0, 0, 0);
}
rows.push(totalRow);
// Sub-group totals if grouping
if (groupBy !== "none") {
for (const g of groups) {
const subRow: (string | number)[] = [
` ${g.label} (${g.resources.length})`, "", g.monthTotals[0]?.totalFte ?? 0,
g.monthTotals[0]?.target ?? 0, "", "", "", "", "",
];
for (const mt of g.monthTotals) {
subRow.push(Math.round(mt.chg * 100) / 100, 0, 0, 0, 0, 0, 0, 0);
}
rows.push(subRow);
}
rows.push([]); // blank separator
}
// Resource rows
for (const r of resources) {
const row: (string | number)[] = [
r.displayName, r.eid, r.fte, Math.round(r.targetPct * 100) / 100,
r.country ?? "", r.city ?? "", r.orgUnit ?? "", r.mgmtGroup ?? "", r.mgmtLevel ?? "",
];
for (const m of r.months) {
row.push(
Math.round(m.chg * 1000) / 1000,
Math.round(m.bd * 1000) / 1000,
Math.round(m.mdi * 1000) / 1000,
Math.round(m.mo * 1000) / 1000,
Math.round(m.pdr * 1000) / 1000,
Math.round(m.absence * 1000) / 1000,
Math.round(m.unassigned * 1000) / 1000,
Math.round(m.sah * 100) / 100,
);
}
rows.push(row);
}
const ws = XLSX.utils.aoa_to_sheet(rows);
XLSX.utils.book_append_sheet(wb, ws, "Chargeability");
XLSX.writeFile(wb, `chargeability-report.xlsx`);
}
function exportToCsv(
resources: ResourceRow[],
monthKeys: string[],
) {
const headers = ["Name", "EID", "FTE", "Target", "Country", "City", "Org Unit", "Mgmt Group", "Mgmt Level"];
for (const key of monthKeys) {
headers.push(`Chg_${key}`, `BD_${key}`, `MDI_${key}`, `MO_${key}`, `PDR_${key}`, `Abs_${key}`, `Free_${key}`, `SAH_${key}`);
}
const lines = [headers.join(",")];
for (const r of resources) {
const vals = [
`"${r.displayName}"`, r.eid, r.fte, r.targetPct,
r.country ?? "", r.city ?? "", r.orgUnit ?? "", r.mgmtGroup ?? "", r.mgmtLevel ?? "",
];
for (const m of r.months) {
vals.push(
m.chg as unknown as string, m.bd as unknown as string, m.mdi as unknown as string,
m.mo as unknown as string, m.pdr as unknown as string, m.absence as unknown as string,
m.unassigned as unknown as string, m.sah as unknown as string,
);
}
lines.push(vals.join(","));
}
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "chargeability-report.csv";
a.click();
URL.revokeObjectURL(url);
}
// ─── Component ───────────────────────────────────────────────────────────────
const NOW = new Date();
const DEFAULT_START = `${NOW.getFullYear()}-${String(NOW.getMonth() + 1).padStart(2, "0")}`;
const DEFAULT_END_DATE = new Date(NOW.getFullYear(), NOW.getMonth() + 5, 1);
const DEFAULT_END = `${DEFAULT_END_DATE.getFullYear()}-${String(DEFAULT_END_DATE.getMonth() + 1).padStart(2, "0")}`;
export function ChargeabilityReportClient() {
const [startMonth, setStartMonth] = useState(DEFAULT_START);
const [endMonth, setEndMonth] = useState(DEFAULT_END);
const [orgUnitId, setOrgUnitId] = useState<string>("");
const [mgmtGroupId, setMgmtGroupId] = useState<string>("");
const [countryId, setCountryId] = useState<string>("");
const [groupBy, setGroupBy] = useState<GroupByField>("none");
const [expandedResource, setExpandedResource] = useState<string | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
// Filter dropdowns data
const orgUnitsQuery = trpc.orgUnit.list.useQuery();
const mgmtGroupsQuery = trpc.managementLevel.listGroups.useQuery();
const countriesQuery = trpc.country.list.useQuery();
const reportQuery = trpc.chargeabilityReport.getReport.useQuery(
{
startMonth,
endMonth,
...(orgUnitId ? { orgUnitId } : {}),
...(mgmtGroupId ? { managementLevelGroupId: mgmtGroupId } : {}),
...(countryId ? { countryId } : {}),
},
{ placeholderData: (prev) => prev },
);
const data = reportQuery.data;
const orgUnits = useMemo(() => {
const items = orgUnitsQuery.data ?? [];
return items.filter((u: { level: number }) => u.level === 7);
}, [orgUnitsQuery.data]);
// Group resources by selected dimension
const groups = useMemo((): GroupSummary[] => {
if (!data || groupBy === "none") return [];
const buckets = new Map<string, ResourceRow[]>();
for (const r of data.resources) {
let key: string;
switch (groupBy) {
case "orgUnit": key = r.orgUnit ?? "(No Org Unit)"; break;
case "mgmtGroup": key = r.mgmtGroup ?? "(No Mgmt Group)"; break;
case "country": key = r.country ?? "(No Country)"; break;
}
const list = buckets.get(key) ?? [];
list.push(r);
buckets.set(key, list);
}
return Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([label, resources]) => ({
label,
resources,
monthTotals: computeGroupMonthTotals(resources, data.monthKeys),
}));
}, [data, groupBy]);
const toggleGroup = useCallback((label: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}, []);
const handleExportExcel = useCallback(() => {
if (!data) return;
exportToExcel(data.resources, data.monthKeys, data.groupTotals, groups, groupBy);
}, [data, groups, groupBy]);
const handleExportCsv = useCallback(() => {
if (!data) return;
exportToCsv(data.resources, data.monthKeys);
}, [data]);
// ─── Render helpers ──────────────────────────────────────────────────────
function renderResourceRow(r: ResourceRow) {
return (
<tr
key={r.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
onClick={() => setExpandedResource(expandedResource === r.id ? null : r.id)}
>
<td className="sticky left-0 z-10 bg-white dark:bg-gray-900 px-3 py-2">
<div className="font-medium text-gray-900 dark:text-gray-100">{r.displayName}</div>
<div className="text-xs text-gray-400">
{[r.eid, r.country, r.city, r.orgUnit].filter(Boolean).join(" | ")}
</div>
</td>
<td className="px-2 py-2 text-center text-gray-600 dark:text-gray-400">{r.fte.toFixed(2)}</td>
<td className="px-2 py-2 text-center text-gray-600 dark:text-gray-400">{pct(r.targetPct)}</td>
{r.months.map((m) => (
<td key={m.monthKey} className="px-2 py-2 text-center">
<div
className={`rounded px-1 ${chgColor(m.chg, r.targetPct)}`}
style={barStyle(m.chg, m.chg >= r.targetPct ? "rgba(34,197,94,0.15)" : "rgba(239,68,68,0.1)")}
>
{pct(m.chg)}
</div>
</td>
))}
</tr>
);
}
function renderExpandedRow(r: ResourceRow) {
if (expandedResource !== r.id) return null;
return (
<tr key={`${r.id}-detail`} className="bg-gray-50 dark:bg-gray-800/30">
<td className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-800/30 px-3 py-2" colSpan={3}>
<div className="text-xs space-y-0.5 text-gray-500 dark:text-gray-400">
<div>Mgmt: {r.mgmtGroup ?? "—"} / {r.mgmtLevel ?? "—"}</div>
<div className="mt-1 grid grid-cols-7 gap-1 text-[10px]">
<span className="font-medium">Chg</span>
<span className="font-medium">BD</span>
<span className="font-medium">MD&I</span>
<span className="font-medium">M&O</span>
<span className="font-medium">PD&R</span>
<span className="font-medium">Abs</span>
<span className="font-medium">Free</span>
</div>
</div>
</td>
{r.months.map((m) => (
<td key={m.monthKey} className="px-2 py-2 text-center">
<div className="grid grid-cols-1 gap-0.5 text-[10px] text-gray-500 dark:text-gray-400">
<span className="text-green-600">{pct(m.chg)}</span>
<span>{pct(m.bd)}</span>
<span>{pct(m.mdi)}</span>
<span>{pct(m.mo)}</span>
<span>{pct(m.pdr)}</span>
<span className="text-orange-500">{pct(m.absence)}</span>
<span className="text-gray-400">{pct(m.unassigned)}</span>
</div>
</td>
))}
</tr>
);
}
function renderGroupTotalsRow(
label: string,
monthTotals: GroupSummary["monthTotals"],
count: number,
isOverall: boolean,
onClick?: () => void,
) {
const bg = isOverall
? "bg-brand-50 dark:bg-brand-900/20"
: "bg-indigo-50 dark:bg-indigo-900/20";
return (
<tr
className={`${bg} font-semibold ${onClick ? "cursor-pointer" : ""}`}
onClick={onClick}
>
<td className={`sticky left-0 z-10 ${bg} px-3 py-2 text-gray-900 dark:text-gray-100`}>
{onClick && <span className="mr-1 text-xs">{expandedGroups.has(label) ? "▾" : "▸"}</span>}
{label} ({count} resources)
</td>
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">
{monthTotals[0]?.totalFte.toFixed(1)}
</td>
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">
{monthTotals[0] ? pct(monthTotals[0].target) : "—"}
</td>
{monthTotals.map((mt) => (
<td key={mt.monthKey} className="px-2 py-2 text-center">
<div className={chgColor(mt.chg, mt.target)}>{pct(mt.chg)}</div>
{mt.gap !== 0 && (
<div className="text-xs text-gray-400">
{mt.gap > 0 ? "+" : ""}{pct(mt.gap)}
</div>
)}
</td>
))}
</tr>
);
}
// ─── Main render ─────────────────────────────────────────────────────────
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">
Chargeability Forecast
</h1>
{/* Export buttons */}
{data && data.resources.length > 0 && (
<div className="flex gap-2">
<button
onClick={handleExportExcel}
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
>
Export Excel
</button>
<button
onClick={handleExportCsv}
className="px-3 py-1.5 text-sm bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
Export CSV
</button>
</div>
)}
</div>
{/* Filters */}
<div className="flex flex-wrap gap-4 items-end bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">From</label>
<input
type="month"
value={startMonth}
onChange={(e) => setStartMonth(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">To</label>
<input
type="month"
value={endMonth}
onChange={(e) => setEndMonth(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Country</label>
<select
value={countryId}
onChange={(e) => setCountryId(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">All</option>
{(countriesQuery.data ?? []).map((c: { id: string; code: string; name: string }) => (
<option key={c.id} value={c.id}>{c.code} {c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Org Unit</label>
<select
value={orgUnitId}
onChange={(e) => setOrgUnitId(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">All</option>
{orgUnits.map((u: { id: string; name: string }) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Mgmt Level Group</label>
<select
value={mgmtGroupId}
onChange={(e) => setMgmtGroupId(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">All</option>
{(mgmtGroupsQuery.data ?? []).map((g: { id: string; name: string }) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Group By</label>
<select
value={groupBy}
onChange={(e) => { setGroupBy(e.target.value as GroupByField); setExpandedGroups(new Set()); }}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="none">No Grouping</option>
<option value="orgUnit">Org Unit</option>
<option value="mgmtGroup">Mgmt Level Group</option>
<option value="country">Country</option>
</select>
</div>
</div>
{/* Report table */}
{reportQuery.isLoading && !data ? (
<div className="text-center py-12 text-gray-500">Loading report...</div>
) : reportQuery.error ? (
<div className="text-center py-12 text-red-600">
Error: {reportQuery.error.message}
</div>
) : data && data.resources.length === 0 ? (
<div className="text-center py-12 text-gray-500">
No resources match the current filters.
</div>
) : data ? (
<div className="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded-lg">
<table className="min-w-full text-sm">
<thead>
<tr className="bg-gray-50 dark:bg-gray-800">
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400 min-w-[200px]">
Resource
</th>
<th className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 w-16">FTE</th>
<th className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 w-16">Target</th>
{data.monthKeys.map((key) => (
<th key={key} className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 min-w-[80px]">
{formatMonth(key)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{/* Overall group total */}
{renderGroupTotalsRow("Group Total", data.groupTotals, data.resources.length, true)}
{/* Grouped view */}
{groupBy !== "none" ? (
groups.map((g) => (
<React.Fragment key={g.label}>
{renderGroupTotalsRow(g.label, g.monthTotals, g.resources.length, false, () => toggleGroup(g.label))}
{expandedGroups.has(g.label) && g.resources.map((r) => (
<React.Fragment key={r.id}>
{renderResourceRow(r)}
{renderExpandedRow(r)}
</React.Fragment>
))}
</React.Fragment>
))
) : (
data.resources.map((r) => (
<React.Fragment key={r.id}>
{renderResourceRow(r)}
{renderExpandedRow(r)}
</React.Fragment>
))
)}
</tbody>
</table>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { formatDate } from "~/lib/format.js";
interface Props {
resourceId: string;
aiSummary: string | null;
aiSummaryUpdatedAt: Date | string | null;
onGenerated: () => void;
}
export function AiSummaryCard({ resourceId, aiSummary, aiSummaryUpdatedAt, onGenerated }: Props) {
const [localSummary, setLocalSummary] = useState<string | null>(aiSummary);
const [localUpdatedAt, setLocalUpdatedAt] = useState<Date | string | null>(aiSummaryUpdatedAt);
const [error, setError] = useState<string | null>(null);
const { canEdit } = usePermissions();
// Keep local state in sync if the parent refreshes with newer data
if (aiSummary && aiSummary !== localSummary) {
setLocalSummary(aiSummary);
setLocalUpdatedAt(aiSummaryUpdatedAt);
}
const generateMutation = trpc.resource.generateAiSummary.useMutation({
onSuccess: (data) => {
setError(null);
setLocalSummary(data.summary);
setLocalUpdatedAt(new Date());
onGenerated();
},
onError: (err) => {
setError(err.message ?? "Failed to generate summary");
},
});
const { data: aiConfigured } = trpc.settings.getAiConfigured.useQuery(undefined, {
staleTime: 5_000,
});
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-800">AI Profile Summary</h2>
{canEdit && aiConfigured?.configured && (
<button
type="button"
onClick={() => generateMutation.mutate({ resourceId })}
disabled={generateMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50 transition-colors"
>
{generateMutation.isPending ? (
<>
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Generating
</>
) : localSummary ? (
"Regenerate"
) : (
"Generate"
)}
</button>
)}
</div>
{error && (
<div className="mb-3 rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-xs text-red-700">
{error}
</div>
)}
{localSummary ? (
<>
<p className="text-sm text-gray-700 leading-relaxed">{localSummary}</p>
{localUpdatedAt && (
<p className="text-xs text-gray-400 mt-2">
Generated {formatDate(localUpdatedAt)}
</p>
)}
</>
) : (
<p className="text-sm text-gray-400 italic">
{!aiConfigured?.configured
? "AI not configured. Set up your API key in Admin → Settings."
: canEdit
? "No summary yet. Click Generate to create one."
: "No summary generated yet."}
</p>
)}
</div>
);
}
@@ -0,0 +1,196 @@
"use client";
import { useState } from "react";
import { FieldType } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
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";
interface Props {
selectedIds: string[];
fieldDefs: BlueprintFieldDefinition[];
onClose: () => void;
onSuccess: () => void;
}
export function BulkEditModal({ selectedIds, fieldDefs, onClose, onSuccess }: Props) {
// Track which fields are included in the bulk update + their new values
const [included, setIncluded] = useState<Set<string>>(new Set());
const [values, setValues] = useState<Record<string, unknown>>({});
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const mutation = trpc.resource.batchUpdateCustomFields.useMutation({
onSuccess: async () => {
await utils.resource.list.invalidate();
onSuccess();
onClose();
},
onError: (err) => setError(err.message),
});
function toggleInclude(key: string) {
setIncluded((prev) => {
const next = new Set(prev);
if (next.has(key)) { next.delete(key); } else { next.add(key); }
return next;
});
}
function setValue(key: string, value: unknown) {
setValues((prev) => ({ ...prev, [key]: value }));
}
function handleSave() {
setError(null);
const fields: Record<string, unknown> = {};
for (const key of included) {
fields[key] = values[key] ?? "";
}
if (Object.keys(fields).length === 0) {
setError("Select at least one field to update.");
return;
}
mutation.mutate({ ids: selectedIds, fields });
}
function handleBackdrop(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === e.currentTarget) onClose();
}
const editableFields = fieldDefs.filter((f) => !f.required || included.has(f.key));
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={handleBackdrop}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<div>
<h2 className="text-lg font-semibold text-gray-900">Bulk Edit Custom Fields</h2>
<p className="text-sm text-gray-500 mt-0.5">
Updating {selectedIds.length} resource{selectedIds.length !== 1 ? "s" : ""}
</p>
</div>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none" aria-label="Close">×</button>
</div>
<div className="px-6 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
{fieldDefs.length === 0 && (
<p className="text-sm text-gray-400 text-center py-6">No custom fields defined. Configure them in Admin Blueprints.</p>
)}
{fieldDefs.map((field) => (
<div key={field.key} className={`border rounded-lg p-3 transition-colors ${included.has(field.key) ? "border-brand-300 bg-brand-50" : "border-gray-200"}`}>
<label className="flex items-center gap-2 mb-2 cursor-pointer">
<input
type="checkbox"
checked={included.has(field.key)}
onChange={() => toggleInclude(field.key)}
className="rounded border-gray-300 text-brand-600"
/>
<span className="text-sm font-medium text-gray-700">{field.label}</span>
{field.required && <span className="text-xs text-red-500">required</span>}
</label>
{included.has(field.key) && (
<FieldInput
field={field}
value={values[field.key]}
onChange={(v) => setValue(field.key, v)}
/>
)}
</div>
))}
</div>
{error && (
<div className="mx-6 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
)}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<p className="text-xs text-gray-400">{included.size} field{included.size !== 1 ? "s" : ""} selected</p>
<div className="flex gap-3">
<button type="button" onClick={onClose} className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium">
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={mutation.isPending || included.size === 0}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{mutation.isPending ? "Saving…" : `Apply to ${selectedIds.length} resource${selectedIds.length !== 1 ? "s" : ""}`}
</button>
</div>
</div>
</div>
</div>
);
}
function FieldInput({ field, value, onChange }: { field: BlueprintFieldDefinition; value: unknown; onChange: (v: unknown) => void }) {
const str = value !== undefined && value !== null ? String(value) : "";
if (field.type === FieldType.BOOLEAN) {
return (
<select value={str} onChange={(e) => onChange(e.target.value === "true")} className={INPUT_CLS}>
<option value=""> select </option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
);
}
if (field.type === FieldType.SELECT && field.options) {
return (
<select value={str} onChange={(e) => onChange(e.target.value)} className={INPUT_CLS}>
<option value=""> select </option>
{field.options.map((o) => <option key={o.value} value={o.value}>{o.label || o.value}</option>)}
</select>
);
}
if (field.type === FieldType.NUMBER) {
return (
<input
type="number"
value={str}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : "")}
placeholder={field.placeholder}
className={INPUT_CLS}
/>
);
}
if (field.type === FieldType.DATE) {
return <input type="date" value={str} onChange={(e) => onChange(e.target.value)} className={INPUT_CLS} />;
}
if (field.type === FieldType.TEXTAREA) {
return (
<textarea
value={str}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className={`${INPUT_CLS} w-full resize-none`}
rows={3}
/>
);
}
return (
<input
type={field.type === FieldType.EMAIL ? "email" : field.type === FieldType.URL ? "url" : "text"}
value={str}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className={`${INPUT_CLS} w-full`}
/>
);
}
// Re-export for convenience
export type { Props as BulkEditModalProps };
@@ -0,0 +1,312 @@
"use client";
import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSpreadsheet, isSpreadsheetFile } from "~/lib/excel.js";
type ImportStage = "idle" | "preview" | "importing" | "done";
interface ImportResult {
total: number;
created: number;
updated: number;
errors: { row: number; message: string }[];
dryRun: boolean;
message?: string;
}
interface Props {
onClose: () => void;
}
export function ImportModal({ onClose }: Props) {
const [stage, setStage] = useState<ImportStage>("idle");
const [rows, setRows] = useState<Record<string, string>[]>([]);
const [fileName, setFileName] = useState<string>("");
const [fileError, setFileError] = useState<string>("");
const [result, setResult] = useState<ImportResult | null>(null);
const [dryRun, setDryRun] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const importMutation = trpc.importExport.importCSV.useMutation({
onSuccess: (data) => {
setResult(data);
setStage("done");
},
onError: (err) => {
setFileError(err.message);
setStage("preview");
},
});
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setFileError("");
setRows([]);
setResult(null);
if (!isSpreadsheetFile(file)) {
setFileError("Unsupported file type. Please upload an Excel (.xlsx, .xls) or CSV file.");
return;
}
setFileName(file.name);
try {
const parsed = await parseSpreadsheet(file);
setRows(parsed);
setStage("preview");
} catch (err) {
setFileError(err instanceof Error ? err.message : "Failed to parse file.");
}
}
function handleImport() {
if (rows.length === 0) return;
setStage("importing");
importMutation.mutate({
entityType: "resources",
rows,
dryRun,
});
}
function handleReset() {
setStage("idle");
setRows([]);
setFileName("");
setFileError("");
setResult(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
const previewHeaders = rows.length > 0 ? Object.keys(rows[0]!) : [];
const previewRows = rows.slice(0, 5);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Import Resources</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* File picker */}
{stage === "idle" && (
<div className="space-y-4">
<p className="text-sm text-gray-600">
Upload an Excel or CSV file to import resources. The first row must contain column headers
matching the resource fields (e.g.{" "}
<code className="px-1 py-0.5 bg-gray-100 rounded text-xs font-mono">
eid, displayName, email, chapter, lcrCents
</code>
).
</p>
<label className="block">
<span className="sr-only">Choose spreadsheet file</span>
<div
className="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-gray-300 rounded-xl cursor-pointer hover:border-brand-400 hover:bg-brand-50 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<svg className="w-10 h-10 text-gray-400 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 13h6m-3-3v6m5 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>
<p className="text-sm text-gray-500">Click to select Excel or CSV</p>
<p className="text-xs text-gray-400 mt-1">.xlsx, .xls, .csv supported</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
className="hidden"
onChange={handleFileChange}
/>
</label>
{fileError && (
<p className="text-sm text-red-600">{fileError}</p>
)}
</div>
)}
{/* Preview */}
{(stage === "preview" || stage === "importing") && rows.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900">{fileName}</p>
<p className="text-xs text-gray-500">{rows.length} row{rows.length !== 1 ? "s" : ""} parsed</p>
</div>
<button
type="button"
onClick={handleReset}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Choose different file
</button>
</div>
{previewHeaders.length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Preview (first {previewRows.length} of {rows.length} rows)
</p>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="text-xs w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{previewHeaders.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{previewRows.map((row, i) => (
<tr key={i} className="hover:bg-gray-50">
{previewHeaders.map((h) => (
<td key={h} className="px-3 py-2 text-gray-700 whitespace-nowrap max-w-[200px] truncate">
{row[h] ?? ""}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{rows.length > 5 && (
<p className="text-xs text-gray-400 mt-1">and {rows.length - 5} more rows</p>
)}
</div>
)}
{fileError && (
<p className="text-sm text-red-600">{fileError}</p>
)}
{/* Import options */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="dryRun"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="dryRun" className="text-sm text-gray-700">
Dry run (validate only, do not write to database)
</label>
</div>
</div>
)}
{/* Done */}
{stage === "done" && result && (
<div className="space-y-4">
<div className={`rounded-lg p-4 ${result.errors.length > 0 ? "bg-yellow-50 border border-yellow-200" : "bg-green-50 border border-green-200"}`}>
<p className="text-sm font-medium text-gray-900 mb-1">
{result.dryRun ? "Dry run complete" : "Import complete"}
</p>
<ul className="text-sm text-gray-700 space-y-0.5">
<li>Total rows: <strong>{result.total}</strong></li>
{!result.dryRun && (
<>
<li>Created: <strong>{result.created}</strong></li>
<li>Updated: <strong>{result.updated}</strong></li>
</>
)}
{result.message && <li>{result.message}</li>}
{result.errors.length > 0 && (
<li className="text-red-600">Errors: <strong>{result.errors.length}</strong></li>
)}
</ul>
</div>
{result.errors.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Errors</p>
<ul className="text-xs text-red-600 space-y-0.5 max-h-32 overflow-y-auto">
{result.errors.map((e, i) => (
<li key={i}>Row {e.row}: {e.message}</li>
))}
</ul>
</div>
)}
{result.dryRun && result.errors.length === 0 && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="dryRunConfirm"
checked={false}
onChange={() => {
setDryRun(false);
setStage("preview");
setResult(null);
}}
className="rounded border-gray-300"
/>
<label htmlFor="dryRunConfirm" className="text-sm text-gray-700 cursor-pointer">
Validation passed click here to run the actual import
</label>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Close
</button>
{(stage === "preview") && (
<button
type="button"
onClick={handleImport}
disabled={rows.length === 0 || importMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 transition-colors"
>
{dryRun ? "Validate" : "Import"} {rows.length} row{rows.length !== 1 ? "s" : ""}
</button>
)}
{stage === "importing" && (
<span className="px-4 py-2 text-sm text-gray-500 animate-pulse">Importing</span>
)}
{stage === "done" && (
<button
type="button"
onClick={handleReset}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Import another file
</button>
)}
</div>
</div>
</div>
);
}

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