feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish

Dashboard: expanded chargeability widget, resource/project table widgets
with sorting and filters, stat cards with formatMoney integration.

Chargeability: new report client with filtering, chargeability-bookings
use case, updated dashboard overview logic.

Dispo import: TBD project handling, parse-dispo-matrix improvements,
stage-dispo-projects resource value scores, new tests.

Estimates: CommercialTermsEditor component, commercial-terms engine
module, expanded estimate schemas and types.

UI: AppShell navigation updates, timeline filter/toolbar enhancements,
role management improvements, signin page redesign, Tailwind/globals
polish, SystemSettings SMTP section, anonymization support.

Tests: new router tests (anonymization, chargeability, effort-rule,
entitlement, estimate, experience-multiplier, notification, resource,
staffing, vacation).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-14 23:29:07 +01:00
parent ad0855902b
commit 625a842d89
74 changed files with 11680 additions and 1583 deletions
@@ -2,18 +2,71 @@
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, formatMoney } 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"];
type EstimateMetric = {
id: string;
key: string;
label: string;
valueDecimal: number;
valueCents: number | null;
currency: string | null;
};
type EstimateScopeItem = {
id: string;
name: string;
description: string | null;
scopeType: string;
};
type EstimateDemandLine = {
id: string;
name: string;
hours: number;
costTotalCents: number;
priceTotalCents: number;
currency: string;
chapter: string | null;
};
type EstimateVersion = {
versionNumber: number;
label: string | null;
status: EstimateVersionStatus;
notes: string | null;
metrics: EstimateMetric[];
scopeItems: EstimateScopeItem[];
demandLines: EstimateDemandLine[];
};
type EstimateProjectRef = {
shortCode: string;
name: string;
};
type EstimateListItem = {
id: string;
name: string;
status: EstimateStatus;
opportunityId: string | null;
updatedAt: Date | string;
project: EstimateProjectRef | null;
versions: Array<Pick<EstimateVersion, "versionNumber" | "status">>;
};
type EstimateDetail = {
id: string;
name: string;
status: EstimateStatus;
project: EstimateProjectRef | null;
versions: EstimateVersion[];
};
const STATUS_STYLES: Record<EstimateStatus, string> = {
DRAFT: "bg-slate-100 text-slate-700",
@@ -30,7 +83,7 @@ const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
SUPERSEDED: "bg-zinc-200 text-zinc-700",
};
function formatMetricValue(metric: EstimateDetail["versions"][number]["metrics"][number]) {
function formatMetricValue(metric: EstimateMetric) {
if (metric.valueCents != null) {
return formatMoney(metric.valueCents, metric.currency ?? "EUR");
}
@@ -42,7 +95,13 @@ function formatMetricValue(metric: EstimateDetail["versions"][number]["metrics"]
function getLatestVersion(estimate: EstimateDetail | null | undefined) {
if (!estimate) return null;
return [...estimate.versions].sort((left, right) => right.versionNumber - left.versionNumber)[0] ?? null;
let latest = estimate.versions[0] ?? null;
for (const version of estimate.versions) {
if (!latest || version.versionNumber > latest.versionNumber) {
latest = version;
}
}
return latest;
}
function EstimateDetailPanel({
@@ -58,16 +117,27 @@ function EstimateDetailPanel({
const latestMetrics = latestVersion?.metrics ?? [];
return (
<aside className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<aside className="app-surface h-full p-5">
<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 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 dark:text-gray-50">
{estimate.name}
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{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])}>
<span
className={clsx(
"rounded-full px-3 py-1 text-xs font-semibold",
STATUS_STYLES[estimate.status],
)}
>
{estimate.status.replace("_", " ")}
</span>
</div>
@@ -84,7 +154,7 @@ function EstimateDetailPanel({
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"
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 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
{cloning ? "Cloning..." : "Clone"}
</button>
@@ -98,7 +168,12 @@ function EstimateDetailPanel({
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])}>
<span
className={clsx(
"rounded-full px-2 py-0.5 text-xs font-medium",
VERSION_STYLES[latestVersion.status],
)}
>
{latestVersion.status}
</span>
</div>
@@ -106,25 +181,32 @@ function EstimateDetailPanel({
{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">
<div
key={metric.id}
className="rounded-2xl border border-gray-100 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/70"
>
<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>
<p className="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
{formatMetricValue(metric)}
</p>
</div>
))}
</div>
)}
{latestVersion.notes && (
<div className="mt-5 rounded-2xl border border-gray-100 bg-gray-50 p-4">
<div className="mt-5 rounded-2xl border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900/70">
<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>
<p className="mt-2 text-sm text-gray-700 dark:text-gray-200">{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>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Scope items
</h3>
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
</div>
<div className="mt-3 space-y-2">
@@ -134,12 +216,19 @@ function EstimateDetailPanel({
</p>
) : (
latestVersion.scopeItems.map((item) => (
<div key={item.id} className="rounded-2xl border border-gray-100 px-4 py-3">
<div
key={item.id}
className="rounded-2xl border border-gray-100 px-4 py-3 dark:border-gray-800"
>
<div className="flex items-center justify-between gap-3">
<p className="font-medium text-gray-900">{item.name}</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{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>}
{item.description && (
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
{item.description}
</p>
)}
</div>
))
)}
@@ -148,7 +237,9 @@ function EstimateDetailPanel({
<section>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900">Demand lines</h3>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Demand lines
</h3>
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
</div>
<div className="mt-3 space-y-2">
@@ -158,12 +249,17 @@ function EstimateDetailPanel({
</p>
) : (
latestVersion.demandLines.map((line) => (
<div key={line.id} className="rounded-2xl border border-gray-100 px-4 py-3">
<div
key={line.id}
className="rounded-2xl border border-gray-100 px-4 py-3 dark:border-gray-800"
>
<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>
<p className="font-medium text-gray-900 dark:text-gray-100">{line.name}</p>
<p className="text-sm font-medium text-gray-600 dark:text-gray-300">
{line.hours.toFixed(1)} h
</p>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500">
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{formatMoney(line.costTotalCents, line.currency)} cost</span>
<span>{formatMoney(line.priceTotalCents, line.currency)} sell</span>
{line.chapter && <span>{line.chapter}</span>}
@@ -204,14 +300,21 @@ function EstimateCard({
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",
active
? "border-brand-500 bg-brand-50 shadow-sm dark:bg-brand-950/30"
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm dark:border-gray-800 dark:bg-gray-950 dark:hover:border-gray-700",
!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])}>
<span
className={clsx(
"rounded-full px-2.5 py-1 text-xs font-semibold",
STATUS_STYLES[estimate.status],
)}
>
{estimate.status.replace("_", " ")}
</span>
{estimate.project && (
@@ -220,13 +323,20 @@ function EstimateCard({
</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">
<h3 className="mt-3 text-lg font-semibold text-gray-900 dark:text-gray-50">
{estimate.name}
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{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])}>
<span
className={clsx(
"rounded-full px-2.5 py-1 text-xs font-medium",
VERSION_STYLES[latestVersion.status],
)}
>
v{latestVersion.versionNumber}
</span>
)}
@@ -235,16 +345,20 @@ function EstimateCard({
<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>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
{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>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
{formatDateLong(estimate.updatedAt)}
</p>
</div>
</div>
{!canInspect && (
<p className="mt-4 text-xs text-gray-400">
<p className="mt-4 text-xs text-gray-400 dark:text-gray-500">
Detailed financial breakdown is limited to manager and controller roles.
</p>
)}
@@ -283,23 +397,28 @@ export function EstimatesClient() {
},
);
const estimates = listQuery.data ?? [];
const estimates = (listQuery.data ?? []) as unknown as EstimateListItem[];
const selectedEstimate = useMemo(() => {
if (!canViewCosts) return null;
return detailQuery.data ?? null;
return (detailQuery.data ?? null) as unknown as EstimateDetail | 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="app-page space-y-6">
<div className="app-surface-strong overflow-hidden bg-gradient-to-br from-white via-white to-brand-50 p-6 dark:from-gray-950 dark:via-gray-950 dark:to-brand-950/40">
<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 className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">
Estimating
</p>
<h1 className="mt-2 font-display text-3xl font-semibold text-gray-900 dark:text-gray-50">
Browser-native estimate workspace
</h1>
<p className="mt-2 max-w-3xl text-sm text-gray-600 dark:text-gray-300">
Build structured estimates from live projects, resources, and role data instead of
maintaining a disconnected spreadsheet.
</p>
</div>
{canEdit && (
@@ -319,12 +438,12 @@ export function EstimatesClient() {
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"
className="app-input rounded-2xl px-4 py-3"
/>
<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"
className="app-select w-full rounded-2xl px-4 py-3"
>
<option value="">All statuses</option>
{Object.values(EstimateStatus).map((value) => (
@@ -337,13 +456,15 @@ export function EstimatesClient() {
</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">
<div className="app-surface-strong border-dashed 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">
<div className="app-surface-strong border-dashed px-6 py-14 text-center">
<p className="text-base font-medium text-gray-700 dark:text-gray-100">
No estimates yet
</p>
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
Start with the wizard to create a connected estimate from Planarchy data.
</p>
</div>
@@ -358,7 +479,9 @@ export function EstimatesClient() {
canInspect={canViewCosts}
onSelect={() => {
if (!canViewCosts) return;
setSelectedEstimateId((current) => (current === estimate.id ? current : estimate.id));
setSelectedEstimateId((current) =>
current === estimate.id ? current : estimate.id,
);
}}
/>
))}
@@ -369,15 +492,21 @@ export function EstimatesClient() {
selectedEstimate ? (
<EstimateDetailPanel
estimate={selectedEstimate}
{...(canEdit ? { onClone: (id: string) => cloneMutation.mutate({ sourceEstimateId: id }), cloning: cloneMutation.isPending } : {})}
{...(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 className="app-surface-strong border-dashed 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">
<div className="app-surface-strong border-dashed 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>
)}
@@ -69,8 +69,8 @@ function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: nu
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="min-w-[104px] space-y-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200/80 dark:bg-gray-700/80">
<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>
@@ -104,7 +104,7 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
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",
"inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition",
STATUS_COLORS[project.status] ?? "bg-gray-100 text-gray-700",
)}
title="Click to change status"
@@ -115,7 +115,7 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
</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]">
<div className="absolute left-0 top-full z-20 mt-2 min-w-[160px] rounded-2xl border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-900">
{ALL_STATUSES.map((s) => (
<button
key={s.value}
@@ -123,10 +123,10 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
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",
"w-full rounded-xl px-3 py-2 text-left text-xs transition",
s.value === project.status
? "font-semibold text-gray-400 cursor-default"
: "text-gray-700 hover:bg-gray-50 cursor-pointer",
? "cursor-default font-semibold text-gray-400"
: "cursor-pointer text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
)}
>
<span className={clsx("inline-block px-1.5 py-0.5 rounded-full", STATUS_COLORS[s.value] ?? "bg-gray-100 text-gray-700")}>
@@ -226,19 +226,30 @@ export function ProjectsClient() {
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = trpc.project.listWithCosts.useInfiniteQuery(
// Keep this boundary shallow; full TRPC inference here can trip TS depth limits.
} = (trpc.project.listWithCosts.useInfiniteQuery as any)(
{
search: search || undefined,
status: (statusFilter as ProjectStatus) || undefined,
limit: 50,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
getNextPageParam: (lastPage: { nextCursor?: string | null }) => lastPage.nextCursor ?? undefined,
initialCursor: undefined,
placeholderData: (prev) => prev,
placeholderData: (prev: { pages: { projects: ProjectRow[]; nextCursor?: string | null }[] } | undefined) => prev,
staleTime: 15_000,
},
);
) as {
data:
| {
pages: { projects: ProjectRow[]; nextCursor?: string | null }[];
}
| undefined;
isLoading: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => Promise<unknown>;
hasNextPage: boolean | undefined;
};
const allProjects = useMemo(
() => (data?.pages.flatMap((p) => p.projects) ?? []) as unknown as ProjectRow[],
@@ -297,16 +308,16 @@ export function ProjectsClient() {
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>;
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{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>;
return <td key={col.key} className="px-4 py-3 text-sm font-mono font-medium text-gray-900 dark:text-gray-100">{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">
<td key={col.key} className="max-w-xs truncate px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
<Link href={`/projects/${project.id}`} className="transition hover:text-brand-600 hover:underline">
{project.name}
</Link>
</td>
@@ -332,14 +343,14 @@ export function ProjectsClient() {
);
case "dates":
return (
<td key={col.key} className="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">
<td key={col.key} className="whitespace-nowrap px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{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">
<div className="mb-0.5 text-sm text-gray-900 dark:text-gray-100">
{(project.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })}
</div>
<BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} />
@@ -347,14 +358,14 @@ export function ProjectsClient() {
);
case "allocations":
return (
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 text-right">
<td key={col.key} className="px-4 py-3 text-right text-sm text-gray-600 dark:text-gray-300">
{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>;
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"></td>;
default:
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600"></td>;
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300"></td>;
}
}
@@ -374,20 +385,19 @@ export function ProjectsClient() {
);
}
return (
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
{col.label}
</th>
);
}
return (
<>
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div className="app-page space-y-5">
<div className="app-page-header gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Projects</h1>
<h1 className="app-page-title">Projects</h1>
{!isLoading && (
<p className="text-gray-500 text-sm mt-1">
<p className="app-page-subtitle mt-1">
{projects.length} project{projects.length !== 1 ? "s" : ""}
{hasNextPage ? "+" : ""}
</p>
@@ -397,7 +407,7 @@ export function ProjectsClient() {
<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"
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-brand-700"
>
<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" />
@@ -407,7 +417,7 @@ export function ProjectsClient() {
<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"
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
<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" />
@@ -417,19 +427,18 @@ export function ProjectsClient() {
</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"
className="app-input max-w-xs"
/>
<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"
className="app-select"
>
<option value="">All Statuses</option>
{ALL_STATUSES.map((s) => (
@@ -439,7 +448,7 @@ export function ProjectsClient() {
<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"
className="app-select"
>
<option value="">All Types</option>
{ALL_ORDER_TYPES.map((t) => (
@@ -456,7 +465,7 @@ export function ProjectsClient() {
<button
type="button"
onClick={resetOrder}
className="text-xs text-gray-500 hover:text-gray-700 underline whitespace-nowrap"
className="whitespace-nowrap text-xs text-gray-500 underline transition hover:text-gray-700 dark:hover:text-gray-200"
title="Clear manual row order"
>
Reset order
@@ -464,22 +473,20 @@ export function ProjectsClient() {
)}
</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">
<div className="app-data-table">
{isLoading ? (
<div className="py-16 text-center text-sm text-gray-400 animate-pulse">Loading projects</div>
<div className="py-16 text-center text-sm text-gray-500 animate-pulse">Loading projects</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<thead className="border-b border-gray-200 dark:border-gray-700">
<tr>
{/* Drag handle column */}
<th className="w-8 px-2" />
@@ -491,7 +498,7 @@ export function ProjectsClient() {
if (el) el.indeterminate = selection.isIndeterminate(projectIds);
}}
onChange={() => selection.toggleAll(projectIds)}
className="rounded border-gray-300"
className="rounded border-gray-300 dark:border-gray-600"
/>
</th>
{visibleColumns.map(renderHeader)}
@@ -500,7 +507,7 @@ export function ProjectsClient() {
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{projects.map((project) => {
const isSelected = selection.selectedIds.has(project.id);
return (
@@ -509,14 +516,14 @@ export function ProjectsClient() {
id={project.id}
dragRef={rowDragRef}
onDrop={(draggedId) => reorder(draggedId, project.id)}
className={`hover:bg-gray-50 transition-colors ${isSelected ? "bg-brand-50" : ""}`}
className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}
>
<td className="px-4 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => selection.toggle(project.id)}
className="rounded border-gray-300"
className="rounded border-gray-300 dark:border-gray-600"
/>
</td>
{visibleColumns.map((col) => renderCell(col, project))}
@@ -525,11 +532,11 @@ export function ProjectsClient() {
<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"
className="text-xs font-medium text-gray-600 transition-colors hover:text-gray-900 hover:underline dark:text-gray-300 dark:hover:text-gray-100"
>
Edit
</button>
<Link href={`/projects/${project.id}`} className="text-xs text-blue-600 hover:text-blue-800 hover:underline font-medium">
<Link href={`/projects/${project.id}`} className="text-xs font-medium text-blue-600 hover:text-blue-800 hover:underline dark:text-blue-300 dark:hover:text-blue-200">
View
</Link>
</div>
@@ -542,7 +549,7 @@ export function ProjectsClient() {
</div>
{projects.length === 0 && (
<div className="text-center py-12 text-gray-500">
<div className="py-14 text-center text-sm text-gray-500">
No projects found.{" "}
<button type="button" onClick={openNewModal} className="text-brand-600 hover:underline font-medium">
Create your first project.
@@ -561,8 +568,8 @@ export function ProjectsClient() {
{/* 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="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Set status for {selection.count} projects</h3>
<div className="flex flex-col gap-1">
{ALL_STATUSES.map((s) => (
<button
@@ -572,7 +579,7 @@ export function ProjectsClient() {
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"
className="w-full rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span className={clsx("inline-block px-2 py-0.5 text-xs rounded-full", STATUS_COLORS[s.value])}>
{s.label}
@@ -614,6 +621,6 @@ export function ProjectsClient() {
{/* Wizard */}
<ProjectWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
</>
</div>
);
}
+1 -5
View File
@@ -1,9 +1,5 @@
import { ProjectsClient } from "./ProjectsClient.js";
export default function ProjectsPage() {
return (
<div className="p-6">
<ProjectsClient />
</div>
);
return <ProjectsClient />;
}
File diff suppressed because it is too large Load Diff
+1 -9
View File
@@ -1,13 +1,5 @@
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>
);
return <StaffingPanel />;
}
+6 -4
View File
@@ -2,10 +2,12 @@ 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 className="app-page flex h-full flex-col gap-5 pb-6">
<div className="app-page-header">
<div>
<h1 className="app-page-title">Timeline</h1>
<p className="app-page-subtitle mt-1">Interactive resource planning timeline</p>
</div>
</div>
<TimelineView />
</div>
@@ -3,6 +3,7 @@ import { createElement } from "react";
import { NextResponse } from "next/server";
import * as XLSX from "xlsx";
import { buildSplitAllocationReadModel } from "@planarchy/application";
import { anonymizeResource, getAnonymizationDirectory } from "@planarchy/api";
import { prisma } from "@planarchy/db";
import type { AllocationLike } from "@planarchy/shared";
import { auth } from "~/server/auth.js";
@@ -52,19 +53,23 @@ export async function GET(request: Request) {
assignments,
});
const assignmentRows = allocationView.assignments.slice(0, 500);
const directory = await getAnonymizationDirectory(prisma);
const rows = assignmentRows.map((a: AllocationLike & {
resource?: { displayName?: string | null } | null;
resource?: { id: string; 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 resource = a.resource ? anonymizeResource(a.resource, directory) : null;
return {
resourceName: 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();
+85 -54
View File
@@ -32,66 +32,97 @@ export default function SignInPage() {
}
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 className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.9),transparent_26rem),linear-gradient(135deg,rgba(240,249,255,1),rgba(232,245,255,0.85)_40%,rgba(255,255,255,1))] px-4 py-12 dark:bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.14),transparent_24rem),linear-gradient(180deg,rgba(12,17,29,1),rgba(10,15,25,1))]">
<div className="mx-auto grid w-full max-w-6xl gap-8 lg:grid-cols-[1.05fr,0.95fr]">
<div className="hidden rounded-[2rem] border border-white/70 bg-white/75 p-10 shadow-2xl backdrop-blur lg:flex lg:flex-col lg:justify-between dark:border-slate-800 dark:bg-slate-950/60">
<div>
<span className="inline-flex rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-900/50 dark:bg-brand-900/20 dark:text-brand-300">
Planarchy Control Center
</span>
<h1 className="mt-6 font-display text-5xl font-semibold leading-tight text-gray-900 dark:text-gray-50">
Resource planning that stays readable under pressure.
</h1>
<p className="mt-5 max-w-xl text-lg text-gray-600 dark:text-gray-300">
Estimates, staffing, chargeability, and timelines in one workspace with sharper structure for day-to-day planning.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="app-surface p-5">
<p className="app-label">Visibility</p>
<p className="text-sm text-gray-700 dark:text-gray-300">Clearer data density, stronger contrast, faster scanning.</p>
</div>
<div className="app-surface p-5">
<p className="app-label">Planning</p>
<p className="text-sm text-gray-700 dark:text-gray-300">Dynamic staffing, resources, and chargeability in one flow.</p>
</div>
<div className="app-surface p-5">
<p className="app-label">Control</p>
<p className="text-sm text-gray-700 dark:text-gray-300">Theme-aware UI that works in bright and dark environments.</p>
</div>
</div>
</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 className="w-full max-w-md lg:ml-auto lg:max-w-lg">
<div className="app-surface-strong p-8">
<div className="mb-8">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-600">Welcome Back</p>
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">Sign in to Planarchy</h2>
<p className="mt-2 text-sm text-gray-500">Resource Planning, staffing, and forecasting.</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
)}
<div>
<label htmlFor="email" className="app-label">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="app-input"
placeholder="admin@planarchy.dev"
required
/>
</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="app-label">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="app-input"
placeholder="••••••••"
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 rounded-2xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-brand-600/25 transition-colors hover:bg-brand-700 disabled:opacity-50"
>
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
<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 className="mt-6 rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-700 dark:bg-gray-900/70">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-gray-500">Demo accounts</p>
<div className="space-y-1.5 text-sm text-gray-600 dark:text-gray-300">
<p><span className="font-mono text-xs">admin@planarchy.dev</span> / admin123</p>
<p><span className="font-mono text-xs">manager@planarchy.dev</span> / manager123</p>
<p><span className="font-mono text-xs">viewer@planarchy.dev</span> / viewer123</p>
</div>
</div>
</div>
</div>
+200 -36
View File
@@ -90,32 +90,36 @@
/* ─── 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 */
--surface-page: 244 246 250;
--surface-card: 255 255 255;
--surface-elevated: 249 250 252;
--surface-input: 255 255 255;
--border-subtle: 219 224 232;
--border-input: 197 205 218;
--text-primary: 18 24 38;
--text-secondary: 71 85 105;
--text-muted: 100 116 139;
--text-very-muted: 148 163 184;
--shadow-soft: 15 23 42 / 0.05;
--shadow-strong: 15 23 42 / 0.12;
}
/* ─── 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 */
--surface-page: 12 17 29;
--surface-card: 16 23 38;
--surface-elevated: 24 33 52;
--surface-input: 18 28 45;
--border-subtle: 41 55 78;
--border-input: 61 79 110;
--text-primary: 237 242 247;
--text-secondary: 196 207 223;
--text-muted: 147 161 185;
--text-very-muted: 118 133 161;
--shadow-soft: 2 6 23 / 0.38;
--shadow-strong: 2 6 23 / 0.65;
}
/* ─── Base Layer: Apply variables to body ────────────────────────────────── */
@@ -124,16 +128,55 @@
body {
background-color: rgb(var(--surface-page));
color: rgb(var(--text-primary));
transition: background-color 0.15s ease, color 0.15s ease;
transition:
background-color 0.15s ease,
color 0.15s ease;
background-image:
radial-gradient(circle at top left, rgb(var(--accent-100) / 0.32), transparent 24rem),
linear-gradient(180deg, rgb(255 255 255 / 0.72), transparent 24rem);
text-rendering: optimizeLegibility;
}
.dark body {
background-image:
radial-gradient(circle at top left, rgb(var(--accent-600) / 0.16), transparent 26rem),
linear-gradient(180deg, rgb(15 23 42 / 0.35), transparent 28rem);
}
h1,
h2,
h3,
h4,
[data-page-title="true"] {
font-family: var(--font-display), system-ui, sans-serif;
letter-spacing: -0.025em;
}
/* Smooth transition for theme changes */
*, *::before, *::after {
*,
*::before,
*::after {
transition-property: background-color, border-color, color;
transition-duration: 0.1s;
transition-timing-function: ease;
}
input,
select,
textarea,
button {
font: inherit;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
button:focus-visible,
a:focus-visible {
outline: 2px solid rgb(var(--accent-500));
outline-offset: 2px;
}
/* Scrollbar styling for dark mode */
.dark ::-webkit-scrollbar {
width: 8px;
@@ -155,6 +198,10 @@
background-color: rgb(var(--surface-card)) !important;
}
.bg-white {
box-shadow: 0 18px 42px -28px rgb(var(--shadow-strong));
}
.dark .bg-gray-50 {
background-color: rgb(var(--surface-elevated)) !important;
}
@@ -199,6 +246,32 @@
color: rgb(var(--text-very-muted)) !important;
}
.text-gray-900 {
color: rgb(var(--text-primary)) !important;
}
.text-gray-800,
.text-gray-700 {
color: rgb(var(--text-secondary)) !important;
}
.text-gray-600,
.text-gray-500 {
color: rgb(var(--text-muted)) !important;
}
.text-gray-400 {
color: rgb(var(--text-very-muted)) !important;
}
.border-gray-200 {
border-color: rgb(var(--border-subtle)) !important;
}
.border-gray-300 {
border-color: rgb(var(--border-input)) !important;
}
.dark input,
.dark select,
.dark textarea {
@@ -222,26 +295,117 @@
}
/* 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; }
.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;
}
@layer components {
.app-surface {
@apply rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900/92;
}
.app-surface-strong {
@apply rounded-3xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900;
}
.app-toolbar {
@apply rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm backdrop-blur;
@apply dark:border-gray-700 dark:bg-gray-900/88 dark:shadow-black/20;
}
.app-input {
@apply w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition;
@apply focus:border-brand-500 focus:ring-4 focus:ring-brand-100/80;
@apply dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:focus:ring-brand-900/50;
}
.app-select {
@apply rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition;
@apply focus:border-brand-500 focus:ring-4 focus:ring-brand-100/80;
@apply dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:focus:ring-brand-900/50;
}
.app-label {
@apply mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400;
}
.app-page {
@apply p-6 md:p-8;
}
.app-page-header {
@apply flex flex-col gap-2 md:flex-row md:items-end md:justify-between;
}
.app-page-title {
@apply font-display text-3xl font-semibold text-gray-900 dark:text-gray-100;
}
.app-page-subtitle {
@apply text-sm text-gray-500 dark:text-gray-400;
}
.app-data-table {
@apply overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900/94;
}
.app-data-table table {
@apply min-w-full text-sm;
}
.app-data-table thead tr {
@apply bg-gray-50/90 dark:bg-gray-800/80;
}
.app-data-table th {
@apply text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400;
}
}
/* ─── Timeline utilities (unchanged) ────────────────────────────────────── */
@layer utilities {
+14 -1
View File
@@ -1,7 +1,20 @@
import type { Metadata } from "next";
import { Manrope, Source_Sans_3 } from "next/font/google";
import { TRPCProvider } from "~/lib/trpc/provider.js";
import "./globals.css";
const uiFont = Source_Sans_3({
subsets: ["latin"],
variable: "--font-ui",
display: "swap",
});
const displayFont = Manrope({
subsets: ["latin"],
variable: "--font-display",
display: "swap",
});
export const metadata: Metadata = {
title: "Planarchy — Resource Planning",
description: "Interactive resource planning and project staffing tool",
@@ -19,7 +32,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
} catch(e) {}
`}} />
</head>
<body className="min-h-screen bg-gray-50 font-sans antialiased">
<body className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -29,7 +29,11 @@ const GridLayout = dynamic(() => import("react-grid-layout").then((m) => m.Respo
ssr: false,
});
function renderWidget(type: DashboardWidgetType, config: DashboardWidgetConfig, onConfigChange: (u: Record<string, unknown>) => void) {
function renderWidget(
type: DashboardWidgetType,
config: DashboardWidgetConfig,
onConfigChange: (u: Record<string, unknown>) => void,
) {
const widget = getWidget(type);
const Component = widget.component;
@@ -73,48 +77,59 @@ export function DashboardClient() {
};
return (
<div className="p-6">
{/* Toolbar */}
<div className="mb-6 flex items-center justify-between">
<div className="app-page space-y-6">
<div className="app-page-header gap-4">
<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>
<h1 className="app-page-title">Dashboard</h1>
<p className="app-page-subtitle mt-1">
Drag widgets to rearrange them and resize from the 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"
className="rounded-xl border border-gray-300 px-3 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
>
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"
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700"
>
<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" />
<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>
<div className="app-surface-strong flex flex-col items-center justify-center border-dashed py-24 text-center">
<p className="text-sm font-medium text-gray-700 dark:text-gray-200">
No widgets on this dashboard yet.
</p>
<p className="mt-2 max-w-md text-sm text-gray-500">
Start with a widget and build a view that matches how your team actually plans and
monitors work.
</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"
className="mt-5 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700"
>
+ Add Widget
Add Widget
</button>
</div>
) : (
<div ref={containerRef}>
<div ref={containerRef} className="app-surface overflow-hidden p-3">
{(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const AnyGridLayout = GridLayout as any;
@@ -128,7 +143,13 @@ export function DashboardClient() {
rowHeight={80}
compactType={null}
preventCollision={false}
onLayoutChange={(_: unknown, allLayouts: Record<string, { i: string; x: number; y: number; w: number; h: number }[]>) => onLayoutChange(allLayouts["lg"] ?? [])}
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]}
>
@@ -138,10 +159,8 @@ export function DashboardClient() {
title={widget.title ?? getWidget(widget.type).label}
onRemove={() => removeWidget(widget.id)}
>
{renderWidget(
widget.type,
widget.config,
(update) => updateWidgetConfig(widget.id, update),
{renderWidget(widget.type, widget.config, (update) =>
updateWidgetConfig(widget.id, update),
)}
</WidgetContainer>
</div>
@@ -152,9 +171,7 @@ export function DashboardClient() {
</div>
)}
{addModalOpen && (
<AddWidgetModal onAdd={addWidget} onClose={() => setAddModalOpen(false)} />
)}
{addModalOpen && <AddWidgetModal onAdd={addWidget} onClose={() => setAddModalOpen(false)} />}
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode, type UIEvent } 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";
@@ -8,27 +8,116 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
type TopSortKey = "name" | "actual" | "expected";
type WatchSortKey = "name" | "actual" | "target";
type CountryOption = {
id: string;
name: string;
};
type ChargeabilityRow = {
id: string;
displayName: string;
chapter: string | null;
chargeabilityTarget: number;
actualChargeability: number;
expectedChargeability: number;
};
function FilterDropdown({ label, children }: { label: string; children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, []);
return (
<div ref={dropdownRef} className="relative">
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
className="inline-flex min-w-44 items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-xs text-gray-700 shadow-sm transition hover:border-gray-400 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200"
>
<span className="truncate">{label}</span>
<span className="text-[10px] text-gray-400">{isOpen ? "▲" : "▼"}</span>
</button>
{isOpen ? (
<div className="absolute right-0 z-20 mt-2 w-72 rounded-2xl border border-gray-200 bg-white p-3 shadow-lg dark:border-gray-700 dark:bg-gray-900">
{children}
</div>
) : null}
</div>
);
}
export function ChargeabilityWidget({ config: _config }: WidgetProps) {
const config = _config as { topN?: number; watchlistThreshold?: number };
const [includeProposed, setIncludeProposed] = useState(false);
const [showDeparted, setShowDeparted] = useState(false);
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
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");
const batchSize = Math.max(config.topN ?? 10, 10);
const [topVisibleCount, setTopVisibleCount] = useState(batchSize);
const [watchVisibleCount, setWatchVisibleCount] = useState(batchSize);
const { data: countriesData } = trpc.country.list.useQuery(undefined, { staleTime: 60_000 });
const countries = useMemo(
() =>
((countriesData ?? []) as Array<{ id: string; name: string }>).map((country) => ({
id: country.id,
name: country.name,
})),
[countriesData],
) as CountryOption[];
const selectedCountryLabel = useMemo(() => {
if (selectedCountryIds.length === 0) return "Countries: All";
if (selectedCountryIds.length === 1) {
return `Country: ${countries.find((country) => country.id === selectedCountryIds[0])?.name ?? "1 selected"}`;
}
return `Countries: ${selectedCountryIds.length} selected`;
}, [countries, selectedCountryIds]);
function toggleTop(key: TopSortKey) {
if (topSort === key) setTopDir((d) => (d === "asc" ? "desc" : "asc"));
else { setTopSort(key); setTopDir(key === "name" ? "asc" : "desc"); }
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"); }
else {
setWatchSort(key);
setWatchDir(key === "name" ? "asc" : "asc");
}
}
const { data, isLoading } = trpc.dashboard.getChargeabilityOverview.useQuery(
{ topN: config.topN ?? 10, watchlistThreshold: config.watchlistThreshold ?? 15 },
{
includeProposed,
topN: config.topN ?? 10,
watchlistThreshold: config.watchlistThreshold ?? 15,
...(selectedCountryIds.length > 0 ? { countryIds: selectedCountryIds } : {}),
...(!showDeparted ? { departed: false } : {}),
},
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
useEffect(() => {
setTopVisibleCount(batchSize);
setWatchVisibleCount(batchSize);
}, [batchSize, includeProposed, selectedCountryIds, showDeparted]);
if (isLoading) {
return (
<div className="animate-pulse flex flex-col gap-3 pt-1">
@@ -59,53 +148,158 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
const rawWatch = data?.watchlist ?? [];
const month = data?.month ?? "";
const top = [...rawTop].sort((a, b) => {
const top = ([...rawTop] as ChargeabilityRow[]).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;
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 watchlist = ([...rawWatch] as ChargeabilityRow[]).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;
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>;
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 watchSort === k ? (
<span className="text-[10px] ml-0.5">{watchDir === "asc" ? "▲" : "▼"}</span>
) : (
<span className="text-[10px] ml-0.5 text-gray-300"></span>
);
}
const visibleTop = top.slice(0, topVisibleCount);
const visibleWatchlist = watchlist.slice(0, watchVisibleCount);
function handleSectionScroll(
event: UIEvent<HTMLElement>,
visibleCount: number,
totalCount: number,
setVisibleCount: (value: number | ((current: number) => number)) => void,
) {
const element = event.currentTarget;
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
if (distanceFromBottom > 48 || visibleCount >= totalCount) {
return;
}
setVisibleCount((current) => Math.min(current + batchSize, totalCount));
}
function toggleCountry(countryId: string, checked: boolean) {
setSelectedCountryIds((current) => {
if (checked) {
return current.includes(countryId) ? current : [...current, countryId];
}
return current.filter((id) => id !== countryId);
});
}
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>
<div className="px-1 flex-shrink-0 flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-gray-400 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>
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
<input
type="checkbox"
checked={includeProposed}
onChange={(event) => setIncludeProposed(event.target.checked)}
className="rounded border-gray-300"
/>
Include proposed
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
<input
type="checkbox"
checked={showDeparted}
onChange={(event) => setShowDeparted(event.target.checked)}
className="rounded border-gray-300"
/>
Show departed
</label>
<FilterDropdown label={selectedCountryLabel}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-gray-600">Countries</p>
{selectedCountryIds.length > 0 ? (
<button
type="button"
onClick={() => setSelectedCountryIds([])}
className="text-[11px] text-brand-600 hover:text-brand-700"
>
Clear
</button>
) : null}
</div>
<p className="text-[11px] text-gray-400">
Empty selection means all countries are included.
</p>
<div className="max-h-48 space-y-1 overflow-y-auto pr-1">
{countries.map((country) => (
<label
key={country.id}
className="flex items-center gap-2 text-xs text-gray-700"
>
<input
type="checkbox"
checked={selectedCountryIds.includes(country.id)}
onChange={(event) => toggleCountry(country.id, event.target.checked)}
className="rounded border-gray-300"
/>
<span>{country.name}</span>
</label>
))}
</div>
</div>
</FilterDropdown>
</div>
</div>
)}
{/* Top list */}
<section className="flex-1 min-h-0 overflow-auto">
<section
className="flex-1 min-h-0 overflow-auto"
onScroll={(event) =>
handleSectionScroll(event, topVisibleCount, top.length, setTopVisibleCount)
}
>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
Top Chargeability
<span className="ml-1 font-normal normal-case text-gray-400">
{visibleTop.length}/{top.length}
</span>
</h3>
{top.length === 0 ? (
<p className="text-xs text-gray-400 px-1">No data available.</p>
@@ -115,28 +309,43 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
<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
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
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."
content="Actual uses CONFIRMED and ACTIVE bookings on active projects. Turn on 'Include proposed' to also count proposed work and imported TBD planning."
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
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."
content="Expected includes all non-CANCELLED bookings this month, including draft projects and proposed planning rows."
width="w-72"
/>
</span>
@@ -144,7 +353,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{top.map((r, i) => (
{visibleTop.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]">
@@ -154,9 +363,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
<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>
<td className="px-2 py-1 text-right text-gray-400">{r.expectedChargeability}%</td>
</tr>
))}
</tbody>
@@ -167,9 +374,17 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
<div className="border-t border-gray-100 flex-shrink-0" />
{/* Watchlist */}
<section className="flex-1 min-h-0 overflow-auto">
<section
className="flex-1 min-h-0 overflow-auto"
onScroll={(event) =>
handleSectionScroll(event, watchVisibleCount, watchlist.length, setWatchVisibleCount)
}
>
<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>
<span className="ml-1 font-normal normal-case text-gray-400">
{visibleWatchlist.length}/{watchlist.length}
</span>
</h3>
{watchlist.length === 0 ? (
<p className="text-xs text-gray-400 px-1">All resources at or near target.</p>
@@ -178,22 +393,37 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
<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
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
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." />
<InfoTooltip content="Actual chargeability this month. By default this counts CONFIRMED and ACTIVE bookings on ACTIVE projects; the toggle can also include PROPOSED work." />
</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
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>
@@ -201,7 +431,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{watchlist.map((r) => (
{visibleWatchlist.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>
@@ -210,9 +440,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
<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>
<td className="px-2 py-1 text-right text-gray-400">{r.chargeabilityTarget}%</td>
</tr>
))}
</tbody>
@@ -22,7 +22,10 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
else {
setSortKey(key);
setSortDir("asc");
}
}
if (isLoading) {
@@ -31,12 +34,19 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
{/* 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
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
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" />
@@ -56,17 +66,24 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
totalCostCents: number;
totalPersonDays: number;
}
const list = ((projects as unknown as { projects: ProjectRow[] } | undefined)?.projects ?? []) as ProjectRow[];
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;
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;
}
});
@@ -79,48 +96,106 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
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"
className="app-input min-w-0 flex-1 text-xs"
/>
<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"
className="app-select text-xs"
>
<option value="">All Statuses</option>
{Object.values(ProjectStatus).map((s) => (
<option key={s} value={s}>{s}</option>
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{/* Table */}
<div className="overflow-auto flex-1">
<div className="app-data-table flex-1 overflow-auto">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<thead className="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">
<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>
<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">
<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>
<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">
<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>
<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">
<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>
<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."
@@ -130,35 +205,57 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
</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">
<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>
<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">
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{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>
<tr key={p.id} className="transition hover:bg-gray-50 dark:hover:bg-gray-800/60">
<td className="px-3 py-2 font-mono text-gray-600 dark:text-gray-300">
{p.shortCode}
</td>
<td className="px-3 py-2 max-w-[180px] truncate font-medium text-gray-900 dark:text-gray-100">
{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] ?? ""}`}>
<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">
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
{(p.totalCostCents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })}
</td>
<td className="px-3 py-2 text-right text-gray-700">{p.totalPersonDays}d</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
{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 className="py-8 text-center text-sm text-gray-400">No projects found.</div>
)}
</div>
</div>
@@ -18,13 +18,14 @@ interface ResourceRow {
export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
const chapter = (config.chapter as string) || "";
const [includeProposed, setIncludeProposed] = useState(false);
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 },
{ chapter: chapter || undefined, includeProposed, startDate, endDate },
{ staleTime: 60_000 },
);
@@ -37,7 +38,10 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
else {
setSortKey(key);
setSortDir("asc");
}
}
if (isLoading) {
@@ -46,12 +50,19 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
{/* 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
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
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" />
@@ -68,77 +79,174 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
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;
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>
)}
<div className="flex flex-wrap items-center gap-3">
{chapters.length > 0 && (
<select
value={chapter}
onChange={(e) => onConfigChange?.({ chapter: e.target.value })}
className="app-select w-44 text-xs"
>
<option value="">All Chapters</option>
{chapters.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
)}
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
<input
type="checkbox"
checked={includeProposed}
onChange={(event) => setIncludeProposed(event.target.checked)}
className="rounded border-gray-300"
/>
Include proposed
</label>
</div>
{/* Table */}
<div className="overflow-auto flex-1">
<div className="app-data-table flex-1 overflow-auto">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<thead className="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">
<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>
<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">
<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>
<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">
<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>
<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">
<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>
<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)." />
<InfoTooltip content="Number of non-cancelled assignments in the period. Proposed rows are only counted when the toggle is enabled." />
</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">
<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>
<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%
Booked hours ÷ available hours × 100 for the period.
<br />
Available hours = working days × hours from personal schedule.
<br />
Proposed rows are only counted when the toggle is enabled.
<br />
<span className="text-orange-300">Orange</span> = &gt;85% ·{" "}
<span className="text-red-300">Red</span> = &gt;100%
</span>
}
width="w-72"
@@ -147,34 +255,59 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
</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">
<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>
<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">
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{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>
<tr
key={r.id}
className={`transition hover:bg-gray-50 dark:hover:bg-gray-800/60 ${r.isOverbooked ? "bg-amber-50 dark:bg-amber-950/20" : ""}`}
>
<td className="px-3 py-2 font-mono text-gray-600 dark:text-gray-300">{r.eid}</td>
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
{r.displayName}
</td>
<td className="px-3 py-2 text-gray-500 dark:text-gray-400">{r.chapter ?? "—"}</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
{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"}`}>
<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>
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400">
{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 className="py-8 text-center text-sm text-gray-400">No resources found.</div>
)}
</div>
</div>
@@ -5,15 +5,25 @@ import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { formatMoney } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
function StatCard({ label, value, sub, info }: { label: string; value: string | number; sub?: string; info?: React.ReactNode }) {
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">
<div className="rounded-2xl border border-gray-200 bg-white/80 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/70">
<span className="flex items-center text-xs font-semibold uppercase tracking-[0.18em] text-gray-500">
{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>}
<span className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-50">{value}</span>
{sub && <span className="mt-1 text-xs text-gray-500 dark:text-gray-400">{sub}</span>}
</div>
);
}
@@ -29,7 +39,10 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
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
key={i}
className="rounded-2xl border border-gray-200 bg-gray-100 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<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" />
@@ -23,7 +23,7 @@ export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffort
{ enabled: showPreview && Boolean(selectedRuleSetId) },
);
const applyMutation = trpc.effortRule.apply.useMutation({
const applyMutation = trpc.effortRule.applyRules.useMutation({
onSuccess: (result) => {
utils.estimate.getById.invalidate();
setShowPreview(false);
@@ -0,0 +1,464 @@
"use client";
import { useEffect, useState } from "react";
import type { CommercialTerms, PaymentMilestone, PricingModel } from "@planarchy/shared";
import {
computeCommercialTermsSummary,
computeMilestoneAmounts,
validatePaymentMilestones,
} from "@planarchy/engine";
import { clsx } from "clsx";
import { formatMoney } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
interface Props {
estimateId: string;
baseCostCents: number;
basePriceCents: number;
baseCurrency: string;
canEdit: boolean;
}
const PRICING_MODELS: Array<{ value: PricingModel; label: string }> = [
{ value: "fixed_price", label: "Fixed Price" },
{ value: "time_and_materials", label: "Time & Materials" },
{ value: "hybrid", label: "Hybrid" },
];
export function CommercialTermsEditor({
estimateId,
baseCostCents,
basePriceCents,
baseCurrency,
canEdit,
}: Props) {
const utils = trpc.useUtils();
const { data, isLoading } = trpc.estimate.getCommercialTerms.useQuery({
estimateId,
});
const updateMutation = trpc.estimate.updateCommercialTerms.useMutation({
onSuccess: async () => {
await utils.estimate.getCommercialTerms.invalidate({ estimateId });
},
});
const [terms, setTerms] = useState<CommercialTerms>({
pricingModel: "fixed_price",
contingencyPercent: 0,
discountPercent: 0,
paymentTermDays: 30,
paymentMilestones: [],
warrantyMonths: 0,
});
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (data?.terms) {
setTerms(data.terms);
setDirty(false);
}
}, [data]);
function update(patch: Partial<CommercialTerms>) {
setTerms((prev) => ({ ...prev, ...patch }));
setDirty(true);
}
function save() {
updateMutation.mutate({
estimateId,
terms,
});
setDirty(false);
}
const summary = computeCommercialTermsSummary({
baseCostCents,
basePriceCents,
terms,
});
const milestoneWarnings = validatePaymentMilestones(terms.paymentMilestones);
const milestoneAmounts = computeMilestoneAmounts(
summary.adjustedPriceCents,
terms.paymentMilestones,
);
if (isLoading) {
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="h-6 w-48 animate-pulse rounded bg-gray-200" />
</div>
);
}
return (
<div className="space-y-6">
{/* Adjusted financials summary */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Cost
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.adjustedCostCents, baseCurrency)}
</p>
{summary.contingencyCents > 0 && (
<p className="mt-1 text-xs text-amber-600">
+{formatMoney(summary.contingencyCents, baseCurrency)} contingency
</p>
)}
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Price
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.adjustedPriceCents, baseCurrency)}
</p>
{summary.discountCents > 0 && (
<p className="mt-1 text-xs text-red-600">
-{formatMoney(summary.discountCents, baseCurrency)} discount
</p>
)}
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Margin
</p>
<p
className={clsx(
"mt-2 text-2xl font-semibold",
summary.adjustedMarginCents >= 0
? "text-emerald-700"
: "text-red-700",
)}
>
{formatMoney(summary.adjustedMarginCents, baseCurrency)}
</p>
<p className="mt-1 text-xs text-gray-500">
{summary.adjustedMarginPercent.toFixed(1)}% of price
</p>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Pricing Model
</p>
<p className="mt-2 text-lg font-semibold text-gray-900">
{PRICING_MODELS.find((m) => m.value === terms.pricingModel)?.label ??
terms.pricingModel}
</p>
{terms.warrantyMonths > 0 && (
<p className="mt-1 text-xs text-gray-500">
{terms.warrantyMonths} mo warranty
</p>
)}
</div>
</div>
{/* Terms editor */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-gray-900">
Commercial Terms
</h3>
{canEdit && dirty && (
<button
type="button"
onClick={save}
disabled={updateMutation.isPending}
className="rounded-lg bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{updateMutation.isPending ? "Saving..." : "Save"}
</button>
)}
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Pricing Model */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Pricing Model
</label>
<select
value={terms.pricingModel}
onChange={(e) =>
update({ pricingModel: e.target.value as PricingModel })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm disabled:bg-gray-50"
>
{PRICING_MODELS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
{/* Contingency % */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Contingency %
</label>
<input
type="number"
min={0}
max={100}
step={0.5}
value={terms.contingencyPercent}
onChange={(e) =>
update({ contingencyPercent: parseFloat(e.target.value) || 0 })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
</div>
{/* Discount % */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Discount %
</label>
<input
type="number"
min={0}
max={100}
step={0.5}
value={terms.discountPercent}
onChange={(e) =>
update({ discountPercent: parseFloat(e.target.value) || 0 })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
</div>
{/* Payment Terms */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Payment Terms (days)
</label>
<input
type="number"
min={0}
max={365}
value={terms.paymentTermDays}
onChange={(e) =>
update({ paymentTermDays: parseInt(e.target.value) || 0 })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
</div>
{/* Warranty */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Warranty (months)
</label>
<input
type="number"
min={0}
max={60}
value={terms.warrantyMonths}
onChange={(e) =>
update({ warrantyMonths: parseInt(e.target.value) || 0 })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
</div>
</div>
{/* Notes */}
<div className="mt-4">
<label className="block text-xs font-medium text-gray-500 mb-1">
Notes
</label>
<textarea
value={terms.notes ?? ""}
onChange={(e) =>
update({ notes: e.target.value || null })
}
disabled={!canEdit}
rows={2}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm disabled:bg-gray-50"
placeholder="Additional commercial notes..."
/>
</div>
</div>
{/* Payment Milestones */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-gray-900">
Payment Milestones
</h3>
{canEdit && (
<button
type="button"
onClick={() =>
update({
paymentMilestones: [
...terms.paymentMilestones,
{ label: "", percent: 0 },
],
})
}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
+ Add milestone
</button>
)}
</div>
{milestoneWarnings.length > 0 && (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 p-3">
<ul className="space-y-1">
{milestoneWarnings.map((w, i) => (
<li key={i} className="text-xs text-amber-700">
{w}
</li>
))}
</ul>
</div>
)}
{terms.paymentMilestones.length === 0 ? (
<p className="text-sm text-gray-400">
No payment milestones defined.
</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">Milestone</th>
<th className="px-3 py-2 text-right font-medium w-24">%</th>
<th className="px-3 py-2 text-right font-medium">Amount</th>
<th className="px-3 py-2 font-medium w-36">Due Date</th>
{canEdit && (
<th className="pl-3 py-2 font-medium w-12" />
)}
</tr>
</thead>
<tbody>
{terms.paymentMilestones.map((ms, idx) => {
const amount = milestoneAmounts[idx];
return (
<tr key={idx} className="border-b border-gray-100">
<td className="py-2 pr-3">
{canEdit ? (
<input
type="text"
value={ms.label}
onChange={(e) => {
const updated = [...terms.paymentMilestones];
updated[idx] = { ...ms, label: e.target.value };
update({ paymentMilestones: updated });
}}
className="w-full rounded border border-gray-200 px-2 py-1 text-sm"
placeholder="e.g. Kickoff"
/>
) : (
<span className="text-gray-900">{ms.label}</span>
)}
</td>
<td className="px-3 py-2 text-right">
{canEdit ? (
<input
type="number"
min={0}
max={100}
step={1}
value={ms.percent}
onChange={(e) => {
const updated = [...terms.paymentMilestones];
updated[idx] = {
...ms,
percent: parseFloat(e.target.value) || 0,
};
update({ paymentMilestones: updated });
}}
className="w-20 rounded border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
/>
) : (
<span className="tabular-nums text-gray-700">
{ms.percent}%
</span>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{amount
? formatMoney(amount.amountCents, baseCurrency)
: "—"}
</td>
<td className="px-3 py-2">
{canEdit ? (
<input
type="date"
value={ms.dueDate ?? ""}
onChange={(e) => {
const updated = [...terms.paymentMilestones];
updated[idx] = {
...ms,
dueDate: e.target.value || null,
};
update({ paymentMilestones: updated });
}}
className="rounded border border-gray-200 px-2 py-1 text-sm"
/>
) : (
<span className="text-gray-700">
{ms.dueDate ?? "—"}
</span>
)}
</td>
{canEdit && (
<td className="pl-3 py-2 text-center">
<button
type="button"
onClick={() => {
const updated = terms.paymentMilestones.filter(
(_, i) => i !== idx,
);
update({ paymentMilestones: updated });
}}
className="text-red-400 hover:text-red-600 text-xs"
title="Remove"
>
X
</button>
</td>
)}
</tr>
);
})}
{/* Total row */}
<tr className="border-t-2 border-gray-300 font-semibold">
<td className="py-2 pr-3 text-gray-900">Total</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">
{terms.paymentMilestones
.reduce((sum, m) => sum + m.percent, 0)
.toFixed(1)}
%
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">
{formatMoney(
milestoneAmounts.reduce((s, a) => s + a.amountCents, 0),
baseCurrency,
)}
</td>
<td />
{canEdit && <td />}
</tr>
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+123 -66
View File
@@ -1,5 +1,6 @@
"use client";
import type { ReactNode } from "react";
import { signOut } from "next-auth/react";
import Link from "next/link";
import type { Route } from "next";
@@ -11,38 +12,83 @@ import { ThemeProvider } from "./ThemeProvider.js";
import { NotificationBell } from "../notifications/NotificationBell.js";
import { NavProgressBar } from "~/components/ui/NavProgressBar.js";
function IconFrame({ children }: { children: ReactNode }) {
return (
<span className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/60 bg-white/80 text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-300">
{children}
</span>
);
}
function DashboardIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 13h6V5H4v8zm10 6h6V5h-6v14zM4 19h6v-2H4v2zm0-4h6v-2H4v2zm10 4h6v-6h-6v6z" /></svg>;
}
function ResourcesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2m18 0v-2a4 4 0 00-3-3.87M14 3.13a4 4 0 010 7.75M12 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>;
}
function ProjectsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 7h18M7 3v4m10-4v4M5 11h14v8H5z" /></svg>;
}
function EstimatesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 7h8M8 11h4m-4 4h8M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg>;
}
function AllocationsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 7V3m8 4V3M4 11h16M5 5h14a1 1 0 011 1v13a1 1 0 01-1 1H5a1 1 0 01-1-1V6a1 1 0 011-1z" /></svg>;
}
function TimelineIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 6h16M4 12h10M4 18h7m9-8h-4m4 6h-7" /></svg>;
}
function StaffingIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 20l9-5-9-5-9 5 9 5zm0-10l9-5-9-5-9 5 9 5zm0 10v-10" /></svg>;
}
function VacationIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 19c3-4 6-6 8-6s5 2 8 6M7 12c.8-2.5 2.5-4 5-4s4.2 1.5 5 4M12 8V4" /></svg>;
}
function RolesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M7 7h10v10H7zM4 4h4m8 0h4m-4 16h4M4 20h4" /></svg>;
}
function SkillsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 3l2.8 5.7 6.2.9-4.5 4.4 1 6.2L12 17.2 6.5 20.2l1-6.2L3 9.6l6.2-.9L12 3z" /></svg>;
}
function ChargeabilityIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M5 17l4-4 3 3 7-8M19 19H5V5" /></svg>;
}
function AdminIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 8a4 4 0 100 8 4 4 0 000-8zm8 4l-2.1.7a7.9 7.9 0 01-.6 1.5l1 2-2.1 2.1-2-1a7.9 7.9 0 01-1.5.6L12 20l-1.7-2.1a7.9 7.9 0 01-1.5-.6l-2 1-2.1-2.1 1-2a7.9 7.9 0 01-.6-1.5L4 12l2.1-1.7a7.9 7.9 0 01.6-1.5l-1-2 2.1-2.1 2 1a7.9 7.9 0 011.5-.6L12 4l1.7 2.1a7.9 7.9 0 011.5.6l2-1 2.1 2.1-1 2a7.9 7.9 0 01.6 1.5L20 12z" /></svg>;
}
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"] },
{ href: "/dashboard", label: "Dashboard", icon: <DashboardIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/resources", label: "Resources", icon: <ResourcesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/projects", label: "Projects", icon: <ProjectsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/estimates", label: "Estimates", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/allocations", label: "Allocations", icon: <AllocationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/timeline", label: "Timeline", icon: <TimelineIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/staffing", label: "Staffing", icon: <StaffingIcon />, roles: ["ADMIN", "MANAGER"] },
{ href: "/vacations", label: "Vacations", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER"] },
{ href: "/vacations/my", label: "My Vacations", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/roles", label: "Roles", icon: <RolesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/skills", label: "Skills Analytics", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, 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: "📥" },
{ href: "/admin/blueprints", label: "Blueprints", icon: <AdminIcon /> },
{ href: "/admin/countries", label: "Countries", icon: <AdminIcon /> },
{ href: "/admin/org-units", label: "Org Units", icon: <AdminIcon /> },
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: <AdminIcon /> },
{ href: "/admin/clients", label: "Clients", icon: <AdminIcon /> },
{ href: "/admin/rate-cards", label: "Rate Cards", icon: <AdminIcon /> },
{ href: "/admin/effort-rules", label: "Effort Rules", icon: <AdminIcon /> },
{ href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: <AdminIcon /> },
{ href: "/admin/management-levels", label: "Mgmt Levels", icon: <AdminIcon /> },
{ href: "/admin/users", label: "Users", icon: <AdminIcon /> },
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
];
const managerNavItems = [
{ href: "/admin/vacations", label: "Vacation Mgmt", icon: "🏖️" },
{ href: "/admin/vacations", label: "Vacation Mgmt", icon: <VacationIcon /> },
];
function Sidebar({ userRole }: { userRole: string }) {
@@ -55,38 +101,45 @@ function Sidebar({ userRole }: { userRole: string }) {
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">
<nav className="w-72 shrink-0 border-r border-white/60 bg-white/80 backdrop-blur-xl dark:border-slate-800 dark:bg-slate-950/75 flex flex-col">
{/* 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 className="border-b border-gray-200/80 px-6 py-6 dark:border-slate-800">
<div className="inline-flex items-center gap-3 rounded-2xl border border-brand-200/70 bg-gradient-to-br from-white to-brand-50 px-4 py-3 shadow-sm dark:border-brand-900/50 dark:from-slate-950 dark:to-slate-900">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-600 text-white shadow-lg shadow-brand-600/25">
<DashboardIcon />
</div>
<div>
<h1 className="font-display text-xl font-semibold text-gray-900 dark:text-gray-50">
Pl<span className="text-brand-600">anarchy</span>
</h1>
<p className="text-xs uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">Resource Planning</p>
</div>
</div>
</div>
{/* Nav links */}
<div className="flex-1 p-4 space-y-1 overflow-y-auto">
<div className="flex-1 space-y-1 overflow-y-auto px-4 py-5">
{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",
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-all",
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",
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
<span>{item.icon}</span>
{item.label}
<IconFrame>{item.icon}</IconFrame>
<span className="flex-1">{item.label}</span>
</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">
<div className="pb-1 pt-4">
<div className="border-t border-gray-200 pt-4 dark:border-slate-800">
<span className="px-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500">
{showAdmin ? "Admin" : "Management"}
</span>
</div>
@@ -96,14 +149,14 @@ function Sidebar({ userRole }: { userRole: string }) {
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",
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-all",
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",
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
<span>{item.icon}</span>
{item.label}
<IconFrame>{item.icon}</IconFrame>
<span className="flex-1">{item.label}</span>
</Link>
))}
{managerNavItems.map((item) => (
@@ -111,14 +164,14 @@ function Sidebar({ userRole }: { userRole: string }) {
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",
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-all",
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",
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
<span>{item.icon}</span>
{item.label}
<IconFrame>{item.icon}</IconFrame>
<span className="flex-1">{item.label}</span>
</Link>
))}
</>
@@ -126,31 +179,35 @@ function Sidebar({ userRole }: { userRole: string }) {
</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">
<div className="space-y-1 border-t border-gray-200/80 p-4 dark:border-slate-800">
<div className="flex items-center gap-2 px-3 py-2">
<NotificationBell />
<span className="text-xs text-gray-400 dark:text-gray-500">Notifications</span>
<span className="text-xs uppercase tracking-[0.16em] 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"
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-100/90 dark:text-gray-300 dark:hover:bg-slate-900"
>
<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
<IconFrame>
<svg className="h-4 w-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>
</IconFrame>
<span>Preferences</span>
</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"
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-100/90 dark:text-gray-300 dark:hover:bg-slate-900"
>
<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
<IconFrame>
<svg className="h-4 w-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>
</IconFrame>
<span>Sign out</span>
</button>
</div>
</nav>
@@ -166,9 +223,9 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
<Suspense>
<NavProgressBar />
</Suspense>
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
<div className="flex h-screen bg-transparent">
<Sidebar userRole={userRole} />
<main className="flex-1 overflow-auto">{children}</main>
<main className="flex-1 overflow-auto bg-transparent">{children}</main>
</div>
</ThemeProvider>
);
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useSession } from "next-auth/react";
import { trpc } from "~/lib/trpc/client.js";
function relativeTime(date: Date): string {
@@ -21,16 +22,20 @@ function relativeTime(date: Date): string {
export function NotificationBell() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { data: session, status } = useSession();
const isAuthenticated = status === "authenticated" && !!session?.user?.email;
const utils = trpc.useUtils();
const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, {
enabled: isAuthenticated,
refetchInterval: 30_000,
retry: false,
});
const { data: notifications = [] } = trpc.notification.list.useQuery(
{ limit: 20 },
{ enabled: open },
{ enabled: open && isAuthenticated, retry: false },
);
const markRead = trpc.notification.markRead.useMutation({
@@ -53,10 +58,12 @@ export function NotificationBell() {
}, [open]);
function handleMarkAllRead() {
if (!isAuthenticated) return;
markRead.mutate({});
}
function handleMarkOne(id: string) {
if (!isAuthenticated) return;
markRead.mutate({ id });
}
@@ -199,6 +199,8 @@ export function ChargeabilityReportClient() {
const [orgUnitId, setOrgUnitId] = useState<string>("");
const [mgmtGroupId, setMgmtGroupId] = useState<string>("");
const [countryId, setCountryId] = useState<string>("");
const [includeProposed, setIncludeProposed] = useState(false);
const [nameSearch, setNameSearch] = useState("");
const [groupBy, setGroupBy] = useState<GroupByField>("none");
const [expandedResource, setExpandedResource] = useState<string | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
@@ -215,6 +217,7 @@ export function ChargeabilityReportClient() {
...(orgUnitId ? { orgUnitId } : {}),
...(mgmtGroupId ? { managementLevelGroupId: mgmtGroupId } : {}),
...(countryId ? { countryId } : {}),
includeProposed,
},
{ placeholderData: (prev) => prev },
);
@@ -226,12 +229,32 @@ export function ChargeabilityReportClient() {
return items.filter((u: { level: number }) => u.level === 7);
}, [orgUnitsQuery.data]);
const filteredResources = useMemo(() => {
if (!data) return [];
const query = nameSearch.trim().toLowerCase();
if (!query) return data.resources;
return data.resources.filter((resource) =>
resource.displayName.toLowerCase().includes(query) ||
resource.eid.toLowerCase().includes(query),
);
}, [data, nameSearch]);
const filteredGroupTotals = useMemo(() => {
if (!data) return [];
return computeGroupMonthTotals(filteredResources, data.monthKeys);
}, [data, filteredResources]);
const averageTarget = filteredGroupTotals[0]?.target ?? 0;
const averageChargeability = filteredGroupTotals[0]?.chg ?? 0;
const averageGap = filteredGroupTotals[0]?.gap ?? 0;
// 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) {
for (const r of filteredResources) {
let key: string;
switch (groupBy) {
case "orgUnit": key = r.orgUnit ?? "(No Org Unit)"; break;
@@ -250,7 +273,7 @@ export function ChargeabilityReportClient() {
resources,
monthTotals: computeGroupMonthTotals(resources, data.monthKeys),
}));
}, [data, groupBy]);
}, [data, filteredResources, groupBy]);
const toggleGroup = useCallback((label: string) => {
setExpandedGroups((prev) => {
@@ -263,13 +286,13 @@ export function ChargeabilityReportClient() {
const handleExportExcel = useCallback(() => {
if (!data) return;
exportToExcel(data.resources, data.monthKeys, data.groupTotals, groups, groupBy);
}, [data, groups, groupBy]);
exportToExcel(filteredResources, data.monthKeys, filteredGroupTotals, groups, groupBy);
}, [data, filteredGroupTotals, filteredResources, groups, groupBy]);
const handleExportCsv = useCallback(() => {
if (!data) return;
exportToCsv(data.resources, data.monthKeys);
}, [data]);
exportToCsv(filteredResources, data.monthKeys);
}, [data, filteredResources]);
// ─── Render helpers ──────────────────────────────────────────────────────
@@ -277,21 +300,21 @@ export function ChargeabilityReportClient() {
return (
<tr
key={r.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
className="cursor-pointer transition-colors hover:bg-gray-50/90 dark:hover:bg-gray-800/50"
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(" | ")}
<td className="sticky left-0 z-10 bg-white/95 px-4 py-3 backdrop-blur dark:bg-slate-950/95">
<div className="font-semibold text-gray-900 dark:text-gray-100">{r.displayName}</div>
<div className="mt-1 text-xs text-gray-500 dark: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>
<td className="px-3 py-3 text-center font-medium text-gray-600 dark:text-gray-300">{r.fte.toFixed(2)}</td>
<td className="px-3 py-3 text-center font-medium text-gray-600 dark:text-gray-300">{pct(r.targetPct)}</td>
{r.months.map((m) => (
<td key={m.monthKey} className="px-2 py-2 text-center">
<td key={m.monthKey} className="px-3 py-3 text-center">
<div
className={`rounded px-1 ${chgColor(m.chg, r.targetPct)}`}
className={`rounded-xl border border-transparent px-2 py-1 font-semibold ${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)}
@@ -305,11 +328,11 @@ export function ChargeabilityReportClient() {
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">
<tr key={`${r.id}-detail`} className="bg-gray-50/80 dark:bg-slate-900/70">
<td className="sticky left-0 z-10 bg-gray-50/95 px-4 py-3 backdrop-blur dark:bg-slate-900/95" colSpan={3}>
<div className="space-y-1 text-xs 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]">
<div className="mt-2 grid grid-cols-7 gap-1 text-[10px] uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500">
<span className="font-medium">Chg</span>
<span className="font-medium">BD</span>
<span className="font-medium">MD&I</span>
@@ -321,15 +344,15 @@ export function ChargeabilityReportClient() {
</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>
<td key={m.monthKey} className="px-3 py-3 text-center">
<div className="grid grid-cols-1 gap-1 rounded-xl bg-white/70 px-2 py-2 text-[10px] text-gray-500 shadow-sm dark:bg-slate-950/40 dark:text-gray-400">
<span className="font-semibold text-green-600 dark:text-green-400">{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>
<span className="text-orange-500 dark:text-orange-300">{pct(m.absence)}</span>
<span className="text-gray-400 dark:text-gray-500">{pct(m.unassigned)}</span>
</div>
</td>
))}
@@ -345,28 +368,28 @@ export function ChargeabilityReportClient() {
onClick?: () => void,
) {
const bg = isOverall
? "bg-brand-50 dark:bg-brand-900/20"
: "bg-indigo-50 dark:bg-indigo-900/20";
? "bg-brand-50/90 dark:bg-brand-900/25"
: "bg-slate-100/90 dark:bg-slate-800/70";
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>}
<td className={`sticky left-0 z-10 ${bg} px-4 py-3 text-gray-900 dark:text-gray-100`}>
{onClick && <span className="mr-2 text-xs text-gray-500 dark:text-gray-400">{expandedGroups.has(label) ? "▾" : "▸"}</span>}
{label} ({count} resources)
</td>
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">
<td className="px-3 py-3 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">
<td className="px-3 py-3 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>
<td key={mt.monthKey} className="px-3 py-3 text-center">
<div className={`font-semibold ${chgColor(mt.chg, mt.target)}`}>{pct(mt.chg)}</div>
{mt.gap !== 0 && (
<div className="text-xs text-gray-400">
<div className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{mt.gap > 0 ? "+" : ""}{pct(mt.gap)}
</div>
)}
@@ -379,23 +402,32 @@ export function ChargeabilityReportClient() {
// ─── 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 */}
<div className="app-page space-y-6">
<div className="app-page-header gap-4">
<div className="space-y-2">
<p className="inline-flex items-center rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-800/80 dark:bg-brand-900/30 dark:text-brand-200">
Forecast Report
</p>
<div>
<h1 className="app-page-title" data-page-title="true">
Chargeability Forecast
</h1>
<p className="app-page-subtitle">
Review expected utilization, search specific people quickly, and compare monthly chargeability against target.
</p>
</div>
</div>
{data && data.resources.length > 0 && (
<div className="flex gap-2">
<div className="flex flex-wrap 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"
className="inline-flex items-center justify-center rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-emerald-700"
>
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"
className="inline-flex items-center justify-center rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
Export CSV
</button>
@@ -403,135 +435,191 @@ export function ChargeabilityReportClient() {
)}
</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"
/>
{data ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="app-surface p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Resources</div>
<div className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">{filteredResources.length}</div>
<div className="mt-1 text-sm text-gray-500">People in the current filter scope</div>
</div>
<div className="app-surface p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Average Chargeability</div>
<div className={`mt-2 text-3xl font-semibold ${chgColor(averageChargeability, averageTarget)}`}>{pct(averageChargeability)}</div>
<div className="mt-1 text-sm text-gray-500">Weighted across visible resources</div>
</div>
<div className="app-surface p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Average Target</div>
<div className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">{pct(averageTarget)}</div>
<div className="mt-1 text-sm text-gray-500">Planning target for the same population</div>
</div>
<div className="app-surface p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Average Gap</div>
<div className={`mt-2 text-3xl font-semibold ${averageGap >= 0 ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"}`}>
{averageGap > 0 ? "+" : ""}{pct(averageGap)}
</div>
<div className="mt-1 text-sm text-gray-500">Difference between chargeability and target</div>
</div>
</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"
/>
) : null}
<div className="app-toolbar">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-7">
<div>
<label className="app-label">From</label>
<input
type="month"
value={startMonth}
onChange={(e) => setStartMonth(e.target.value)}
className="app-input"
/>
</div>
<div>
<label className="app-label">To</label>
<input
type="month"
value={endMonth}
onChange={(e) => setEndMonth(e.target.value)}
className="app-input"
/>
</div>
<div>
<label className="app-label">Country</label>
<select
value={countryId}
onChange={(e) => setCountryId(e.target.value)}
className="app-select w-full"
>
<option value="">All countries</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="app-label">Org Unit</label>
<select
value={orgUnitId}
onChange={(e) => setOrgUnitId(e.target.value)}
className="app-select w-full"
>
<option value="">All org units</option>
{orgUnits.map((u: { id: string; name: string }) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</div>
<div>
<label className="app-label">Mgmt Level Group</label>
<select
value={mgmtGroupId}
onChange={(e) => setMgmtGroupId(e.target.value)}
className="app-select w-full"
>
<option value="">All groups</option>
{(mgmtGroupsQuery.data ?? []).map((g: { id: string; name: string }) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div>
<label className="app-label">Group By</label>
<select
value={groupBy}
onChange={(e) => { setGroupBy(e.target.value as GroupByField); setExpandedGroups(new Set()); }}
className="app-select w-full"
>
<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 className="md:col-span-2 xl:col-span-1 2xl:col-span-1">
<label className="app-label">Name Search</label>
<input
type="search"
value={nameSearch}
onChange={(e) => setNameSearch(e.target.value)}
placeholder="Search by name or EID"
className="app-input"
/>
</div>
</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 className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<label className="inline-flex items-center gap-3 text-sm font-medium text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={includeProposed}
onChange={(e) => setIncludeProposed(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span>Include proposed work in utilization calculations</span>
</label>
<div className="text-xs uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
{groupBy === "none" ? "Flat resource view" : `Grouped by ${groupBy}`}
</div>
</div>
</div>
{/* Report table */}
{reportQuery.isLoading && !data ? (
<div className="text-center py-12 text-gray-500">Loading report...</div>
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">Loading report...</div>
) : reportQuery.error ? (
<div className="text-center py-12 text-red-600">
<div className="app-surface-strong py-16 text-center text-sm text-red-600 dark:text-red-400">
Error: {reportQuery.error.message}
</div>
) : data && data.resources.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
No resources match the current filters.
</div>
) : data && filteredResources.length === 0 ? (
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
No resources match the current search.
</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)}
<div className="app-data-table">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr>
<th className="sticky left-0 z-10 min-w-[240px] bg-gray-50/95 px-4 py-3 text-left backdrop-blur dark:bg-gray-800/95">
Resource
</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)}
<th className="w-20 px-3 py-3 text-center">FTE</th>
<th className="w-24 px-3 py-3 text-center">Target</th>
{data.monthKeys.map((key) => (
<th key={key} className="min-w-[96px] px-3 py-3 text-center">
{formatMonth(key)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{renderGroupTotalsRow("Group Total", filteredGroupTotals, filteredResources.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>
{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>
))
) : (
filteredResources.map((r) => (
<React.Fragment key={r.id}>
{renderResourceRow(r)}
{renderExpandedRow(r)}
</React.Fragment>
))
)}
</tbody>
</table>
</div>
</div>
) : null}
</div>
@@ -2,7 +2,6 @@
import { useState } from "react";
import Link from "next/link";
import { useSession } from "next-auth/react";
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { formatDate } from "~/lib/format.js";
@@ -59,10 +58,10 @@ function StatCard({ label, value, sub }: { label: string; value: string | number
export function ResourceDetail({ resourceId }: ResourceDetailProps) {
const [editOpen, setEditOpen] = useState(false);
const [includeProposedChargeability, setIncludeProposedChargeability] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const utils = trpc.useUtils();
const { canViewCosts, canEdit, canViewScores } = usePermissions();
const { data: session } = useSession();
const _resourceQuery = trpc.resource.getById.useQuery({ id: resourceId });
const resource = _resourceQuery.data as unknown as Resource | undefined;
@@ -98,7 +97,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
);
const chargeabilityStatsResult = trpc.resource.getChargeabilityStats.useQuery(
{ resourceId },
{ includeProposed: includeProposedChargeability, resourceId },
{ enabled: canViewCosts, staleTime: 60_000 },
);
const chargeStats = (chargeabilityStatsResult.data as unknown as Array<{
@@ -156,10 +155,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
total: number;
} | null;
valueScoreUpdatedAt?: Date | null;
isOwnedByCurrentUser?: boolean;
};
const currentUserEmail = session?.user?.email;
const isOwner = !!(resourceWithMeta.userId && currentUserEmail &&
(resource as unknown as { user?: { email?: string } }).user?.email === currentUserEmail);
const isOwner = resourceWithMeta.isOwnedByCurrentUser === true;
const canUpload = isOwner || canEdit;
// Compute stats
@@ -260,6 +258,19 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
</div>
{/* Stats cards */}
{canViewCosts && (
<div className="flex justify-end">
<label className="flex items-center gap-2 text-sm text-gray-600">
<input
type="checkbox"
checked={includeProposedChargeability}
onChange={(event) => setIncludeProposedChargeability(event.target.checked)}
className="rounded border-gray-300"
/>
Include proposed in chargeability
</label>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{canViewCosts && (
<StatCard
@@ -278,17 +289,21 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
value={`${resource.chargeabilityTarget}%`}
/>
{canViewCosts && (
<StatCard
label="Actual (this month)"
value={chargeStats != null ? `${chargeStats.actualChargeability}%` : "—"}
sub="Excl. draft projects"
/>
<StatCard
label="Actual (this month)"
value={chargeStats != null ? `${chargeStats.actualChargeability}%` : "—"}
sub={
includeProposedChargeability
? "Incl. proposed + imported TBD planning"
: "Confirmed + active only"
}
/>
)}
{canViewCosts && (
<StatCard
label="Expected (this month)"
value={chargeStats != null ? `${chargeStats.expectedChargeability}%` : "—"}
sub="Incl. draft projects"
sub="All non-cancelled bookings"
/>
)}
<StatCard
@@ -11,6 +11,9 @@ interface RoleAssignment {
isPrimary: boolean;
}
type CountryWithCities = { id: string; metroCities: { id: string; name: string }[] };
type ManagementGroupWithLevels = { id: string; levels: { id: string; name: string }[] };
interface SkillRow {
skill: string;
proficiency: 1 | 2 | 3 | 4 | 5;
@@ -47,6 +50,8 @@ interface FormState {
managementLevelId: string;
resourceType: string;
chgResponsibility: boolean;
rolledOff: boolean;
departed: boolean;
enterpriseId: string;
clientUnitId: string;
fte: string;
@@ -99,6 +104,8 @@ function resourceToFormState(resource: Resource): FormState {
managementLevelId: (resource as unknown as { managementLevelId?: string | null }).managementLevelId ?? "",
resourceType: (resource as unknown as { resourceType?: string }).resourceType ?? "EMPLOYEE",
chgResponsibility: (resource as unknown as { chgResponsibility?: boolean }).chgResponsibility ?? true,
rolledOff: (resource as unknown as { rolledOff?: boolean }).rolledOff ?? false,
departed: (resource as unknown as { departed?: boolean }).departed ?? false,
enterpriseId: (resource as unknown as { enterpriseId?: string | null }).enterpriseId ?? "",
clientUnitId: (resource as unknown as { clientUnitId?: string | null }).clientUnitId ?? "",
fte: String((resource as unknown as { fte?: number }).fte ?? 1),
@@ -133,6 +140,8 @@ function defaultFormState(): FormState {
managementLevelId: "",
resourceType: "EMPLOYEE",
chgResponsibility: true,
rolledOff: false,
departed: false,
enterpriseId: "",
clientUnitId: "",
fte: "1",
@@ -197,11 +206,13 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
const { data: clients } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
// Derive metro cities from selected country
const selectedCountry = (countries ?? []).find((c) => c.id === form.countryId) as unknown as { id: string; metroCities: { id: string; name: string }[] } | undefined;
const countryRows = (countries ?? []) as unknown as CountryWithCities[];
const selectedCountry = countryRows.find((c) => c.id === form.countryId);
const metroCities = selectedCountry?.metroCities ?? [];
// Derive levels from selected group
const selectedGroup = (mgmtGroups ?? []).find((g) => g.id === form.managementLevelGroupId) as unknown as { id: string; levels: { id: string; name: string }[] } | undefined;
const managementGroups = (mgmtGroups ?? []) as unknown as ManagementGroupWithLevels[];
const selectedGroup = managementGroups.find((g) => g.id === form.managementLevelGroupId);
const mgmtLevels = selectedGroup?.levels ?? [];
const createMutation = trpc.resource.create.useMutation({
@@ -294,6 +305,8 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
...(form.managementLevelId ? { managementLevelId: form.managementLevelId } : {}),
resourceType: form.resourceType as ResourceType,
chgResponsibility: form.chgResponsibility,
rolledOff: form.rolledOff,
departed: form.departed,
...(form.enterpriseId.trim() !== "" ? { enterpriseId: form.enterpriseId.trim() } : {}),
...(form.clientUnitId ? { clientUnitId: form.clientUnitId } : {}),
fte: parseFloat(form.fte) || 1,
@@ -628,7 +641,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
</div>
</div>
<div className="grid grid-cols-3 gap-4 mt-4">
<div className="grid grid-cols-4 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-resourceType">Resource Type</label>
<select
@@ -653,6 +666,28 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
Chg Responsibility
</label>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.rolledOff}
onChange={(e) => setField("rolledOff", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Rolled Off
</label>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.departed}
onChange={(e) => setField("departed", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Departed
</label>
</div>
</div>
{/* Section 2: Cost & Chargeability */}
+59 -19
View File
@@ -6,8 +6,16 @@ import { trpc } from "~/lib/trpc/client.js";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
const PRESET_COLORS = [
"#6366f1", "#8b5cf6", "#ec4899", "#ef4444", "#f97316",
"#eab308", "#22c55e", "#14b8a6", "#06b6d4", "#3b82f6",
"#6366f1",
"#8b5cf6",
"#ec4899",
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#14b8a6",
"#06b6d4",
"#3b82f6",
];
interface RoleModalProps {
@@ -69,33 +77,48 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
}
}
const inputClass = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
const labelClass = "block text-sm font-medium text-gray-700 mb-1";
const inputClass = "app-input";
const labelClass = "app-label";
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(); }}
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/55 py-8 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
className="mx-4 w-full max-w-md rounded-3xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900"
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isEditing ? "Edit Role" : "New Role"}
</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
<button
type="button"
onClick={onClose}
className="text-xl leading-none text-gray-400 transition hover:text-gray-600 dark:hover:text-gray-200"
>
&times;
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
<div>
<label className={labelClass}>Name <span className="text-red-500">*</span></label>
<label className={labelClass}>
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => { setName(e.target.value); setServerError(null); }}
onChange={(e) => {
setName(e.target.value);
setServerError(null);
}}
placeholder="e.g. 3D Artist"
className={inputClass}
maxLength={100}
@@ -117,8 +140,16 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
<div>
<label className={labelClass}>Color</label>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full border-2 border-gray-200 flex-shrink-0" style={{ backgroundColor: color }} />
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/70">
<div className="mb-3 flex items-center gap-3">
<div
className="h-8 w-8 flex-shrink-0 rounded-full border-2 border-gray-200 dark:border-gray-600"
style={{ backgroundColor: color }}
/>
<p className="text-sm text-gray-600 dark:text-gray-300">
Pick a color that stays readable in timelines and chips.
</p>
</div>
<div className="flex flex-wrap gap-2 flex-1">
{PRESET_COLORS.map((c) => (
<button
@@ -137,23 +168,32 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 rounded cursor-pointer border border-gray-300"
className="mt-3 h-8 w-10 cursor-pointer rounded border border-gray-300 bg-transparent dark:border-gray-600"
title="Custom color"
/>
</div>
</div>
{serverError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300">
{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 hover:text-gray-900 disabled:opacity-50">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="rounded-xl px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-100 hover:text-gray-900 disabled:opacity-50 dark:text-gray-300 dark:hover:bg-gray-800"
>
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">
<button
type="submit"
disabled={isPending}
className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:opacity-50"
>
{isPending ? "Saving…" : "Save"}
</button>
</div>
+95 -42
View File
@@ -77,37 +77,36 @@ export function RolesClient() {
const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(showInactive ? [{ label: "Inactive included", onRemove: () => setShowInactive(false) }] : []),
...(showInactive
? [{ label: "Inactive included", onRemove: () => setShowInactive(false) }]
: []),
];
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div className="app-page mx-auto max-w-6xl space-y-5">
<div className="app-page-header gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Roles</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage role definitions and resource assignments
</p>
<h1 className="app-page-title">Roles</h1>
<p className="app-page-subtitle mt-1">Manage role definitions and resource assignments</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"
className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700"
>
+ New Role
New Role
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 mb-3">
<div className="app-toolbar flex flex-wrap items-center gap-3">
<input
type="text"
placeholder="Search roles…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64"
className="app-input w-64"
/>
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<label className="flex cursor-pointer items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
<input
type="checkbox"
checked={showInactive}
@@ -125,34 +124,75 @@ export function RolesClient() {
)}
{actionError && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 flex items-center justify-between">
<div className="flex items-center justify-between rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300">
{actionError}
<button type="button" onClick={() => setActionError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
<button
type="button"
onClick={() => setActionError(null)}
className="text-red-400 hover:text-red-600 text-lg leading-none"
>
&times;
</button>
</div>
)}
{/* Table */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="app-data-table">
<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={toggle} />
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Description</th>
<SortableColumnHeader label="Resources" field="resourceRoles" sortField={sortField} sortDir={sortDir} onSort={(f) => toggle(f, (r) => r._count.resourceRoles)} align="center" tooltip="Number of resources that currently have this role assigned (active assignments only)." />
<SortableColumnHeader label="Allocations" field="allocations" sortField={sortField} sortDir={sortDir} onSort={(f) => toggle(f, (r) => r._count.allocations)} align="center" tooltip="Total number of planning entries that use this role, including open-demand compatibility rows." />
<SortableColumnHeader label="Status" field="isActive" sortField={sortField} sortDir={sortDir} onSort={(f) => toggle(f, (r) => (r.isActive ? 0 : 1))} align="center" tooltip="Active roles are available for assignment. Inactive roles are hidden from pickers but existing assignments remain." />
<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 className="border-b border-gray-200 dark:border-gray-700">
<SortableColumnHeader
label="Name"
field="name"
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
/>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
Description
</th>
<SortableColumnHeader
label="Resources"
field="resourceRoles"
sortField={sortField}
sortDir={sortDir}
onSort={(f) => toggle(f, (r) => r._count.resourceRoles)}
align="center"
tooltip="Number of resources that currently have this role assigned (active assignments only)."
/>
<SortableColumnHeader
label="Allocations"
field="allocations"
sortField={sortField}
sortDir={sortDir}
onSort={(f) => toggle(f, (r) => r._count.allocations)}
align="center"
tooltip="Total number of planning entries that use this role, including open-demand compatibility rows."
/>
<SortableColumnHeader
label="Status"
field="isActive"
sortField={sortField}
sortDir={sortDir}
onSort={(f) => toggle(f, (r) => (r.isActive ? 0 : 1))}
align="center"
tooltip="Active roles are available for assignment. Inactive roles are hidden from pickers but existing assignments remain."
/>
<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>
<td colSpan={6} className="py-12 text-center text-gray-400">
Loading
</td>
</tr>
)}
{!isLoading && sorted.length === 0 && (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-400">
<td colSpan={6} className="py-12 text-center text-gray-400">
No roles found. Create one to get started.
</td>
</tr>
@@ -168,7 +208,9 @@ export function RolesClient() {
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: role.color ?? "#6366f1" }}
/>
<span className="font-medium text-gray-900 dark:text-gray-100">{role.name}</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{role.name}
</span>
</div>
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 max-w-xs truncate">
@@ -185,11 +227,13 @@ export function RolesClient() {
</span>
</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.isActive
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400"
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
}`}>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
role.isActive
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400"
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
}`}
>
{role.isActive ? "Active" : "Inactive"}
</span>
</td>
@@ -230,7 +274,10 @@ export function RolesClient() {
)}
<button
type="button"
onClick={() => { setConfirmDelete(role); setActionError(null); }}
onClick={() => {
setConfirmDelete(role);
setActionError(null);
}}
className="text-xs text-red-500 hover:text-red-700"
>
Delete
@@ -243,7 +290,6 @@ export function RolesClient() {
</table>
</div>
{/* Modals */}
{modalOpen && (
<RoleModal
role={editRole}
@@ -253,26 +299,33 @@ export function RolesClient() {
)}
{confirmDelete && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-sm mx-4 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Delete Role</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 px-4 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-6 shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
Delete Role
</h3>
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Are you sure you want to delete <strong>{confirmDelete.name}</strong>?
{(confirmDelete._count.resourceRoles > 0 || confirmDelete._count.allocations > 0) && (
<span className="block mt-1 text-amber-600">
This role is assigned to {confirmDelete._count.resourceRoles} resource(s) and {confirmDelete._count.allocations} allocation(s). Deletion will be blocked.
This role is assigned to {confirmDelete._count.resourceRoles} resource(s) and{" "}
{confirmDelete._count.allocations} allocation(s). Deletion will be blocked.
</span>
)}
</p>
<div className="flex gap-3 justify-end">
<button type="button" onClick={() => setConfirmDelete(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setConfirmDelete(null)}
className="rounded-xl px-4 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100 hover:text-gray-800 dark:text-gray-300 dark:hover:bg-gray-800"
>
Cancel
</button>
<button
type="button"
onClick={() => deleteMutation.mutate({ id: confirmDelete.id })}
disabled={deleteMutation.isPending}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium disabled:opacity-50"
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
>
{deleteMutation.isPending ? "Deleting…" : "Delete"}
</button>
+168 -125
View File
@@ -25,138 +25,181 @@ export function StaffingPanel() {
);
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Search Form */}
<div className="lg:col-span-1">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Search Criteria</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Required Skills
</label>
<SkillTagInput
value={requiredSkills}
onChange={setRequiredSkills}
placeholder="Add skill…"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
<DateInput
value={startDate}
onChange={setStartDate}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">End Date</label>
<DateInput
value={endDate}
onChange={setEndDate}
min={startDate}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Hours per Day</label>
<input
type="number"
value={hoursPerDay}
onChange={(e) => setHoursPerDay(Number(e.target.value))}
min={1}
max={24}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<button
onClick={() => setSubmitted(true)}
className="w-full bg-brand-600 hover:bg-brand-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors"
>
Find Matches
</button>
<div className="app-page space-y-6">
<div className="app-page-header gap-4">
<div className="space-y-3">
<span className="inline-flex items-center rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-brand-700 dark:border-brand-900/60 dark:bg-brand-900/30 dark:text-brand-200">
Staffing
</span>
<div>
<h1 className="app-page-title">Staffing Suggestions</h1>
<p className="app-page-subtitle max-w-2xl">
Match open work with the strongest available people based on skills, availability, utilization, and cost.
</p>
</div>
</div>
<div className="app-surface max-w-xl p-4">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">How scoring works</p>
<p className="mt-1 text-sm text-gray-500">
Planarchy blends skill fit, free capacity, cost, and current utilization. Add the must-have skills first, then narrow the date window to get cleaner results.
</p>
</div>
</div>
{/* Results */}
<div className="lg:col-span-2">
{isLoading && (
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400">
Finding best matches...
</div>
)}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-6">
<div className="app-surface-strong p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Search Criteria</h2>
<p className="mt-1 text-sm text-gray-500">Define the role needs and let the matching engine rank the best candidates.</p>
{suggestions && suggestions.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400">
No resources found matching your criteria.
</div>
)}
<div className="mt-6 space-y-5">
<div>
<label className="app-label">Required Skills</label>
<SkillTagInput
value={requiredSkills}
onChange={setRequiredSkills}
placeholder="Add skill…"
/>
</div>
{suggestions && suggestions.length > 0 && (
<div className="space-y-3">
{suggestions.map((suggestion, idx) => (
<div key={suggestion.resourceId} className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center font-bold text-brand-700">
#{idx + 1}
</div>
<div>
<div className="font-semibold text-gray-900">{suggestion.resourceName}</div>
<div className="text-xs text-gray-500">{suggestion.eid}</div>
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-brand-600">{suggestion.score}</div>
<div className="text-xs text-gray-400">score</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
<div>
<label className="app-label">Start Date</label>
<DateInput
value={startDate}
onChange={setStartDate}
className="app-input"
/>
</div>
<div className="mt-4 grid grid-cols-4 gap-3">
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} />
<ScoreBar label="Avail." value={suggestion.scoreBreakdown.availabilityScore} />
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} />
<ScoreBar label="Util." value={suggestion.scoreBreakdown.utilizationScore} />
</div>
<div className="mt-3 flex flex-wrap gap-2">
{suggestion.matchedSkills.map((skill) => (
<span key={skill} className="px-2 py-0.5 bg-green-50 text-green-700 text-xs rounded-full">
{skill}
</span>
))}
{suggestion.missingSkills.map((skill) => (
<span key={skill} className="px-2 py-0.5 bg-red-50 text-red-600 text-xs rounded-full">
{skill} (missing)
</span>
))}
</div>
<div className="mt-3 flex items-center gap-4 text-xs text-gray-500">
<span>
LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} /h
</span>
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
{suggestion.availabilityConflicts.length > 0 && (
<span className="text-yellow-600"> {suggestion.availabilityConflicts.length} conflicts</span>
)}
<div>
<label className="app-label">End Date</label>
<DateInput
value={endDate}
onChange={setEndDate}
min={startDate}
className="app-input"
/>
</div>
</div>
))}
</div>
)}
{!submitted && (
<div className="bg-white rounded-xl border border-gray-200 border-dashed p-12 text-center text-gray-300">
Fill in the criteria and click "Find Matches" to see staffing suggestions.
<div>
<label className="app-label">Hours per Day</label>
<input
type="number"
value={hoursPerDay}
onChange={(e) => setHoursPerDay(Number(e.target.value))}
min={1}
max={24}
className="app-input"
/>
</div>
<button
onClick={() => setSubmitted(true)}
className="inline-flex w-full items-center justify-center rounded-xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-brand-700"
>
Find Matches
</button>
</div>
</div>
)}
<div className="app-surface p-5">
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Skills</div>
<div className="mt-1 text-gray-600 dark:text-gray-300">Quality of skill overlap with the requested stack.</div>
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Availability</div>
<div className="mt-1 text-gray-600 dark:text-gray-300">Conflicts and free capacity during the selected period.</div>
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Cost + Load</div>
<div className="mt-1 text-gray-600 dark:text-gray-300">Cost efficiency and current utilization weighting.</div>
</div>
</div>
</div>
</div>
<div className="space-y-4">
{isLoading && (
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
Finding best matches...
</div>
)}
{suggestions && suggestions.length === 0 && (
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
No resources found matching your criteria.
</div>
)}
{suggestions && suggestions.length > 0 && (
<div className="space-y-4">
{suggestions.map((suggestion, idx) => (
<div key={suggestion.resourceId} className="app-surface p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-100 font-display text-lg font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-200">
{idx + 1}
</div>
<div>
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</div>
<div className="text-sm text-gray-500">{suggestion.eid}</div>
</div>
</div>
<div className="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3 text-right dark:border-brand-900/50 dark:bg-brand-900/20">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:text-brand-200">Match Score</div>
<div className="mt-1 text-3xl font-semibold text-brand-700 dark:text-brand-100">{suggestion.score}</div>
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} />
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} />
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} />
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} />
</div>
<div className="mt-4 flex flex-wrap gap-2">
{suggestion.matchedSkills.map((skill) => (
<span key={skill} className="rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/30 dark:text-green-300">
{skill}
</span>
))}
{suggestion.missingSkills.map((skill) => (
<span key={skill} className="rounded-full bg-red-50 px-2.5 py-1 text-xs font-medium text-red-600 dark:bg-red-950/30 dark:text-red-300">
{skill} missing
</span>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-gray-500">
<span>LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} /h</span>
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
{suggestion.availabilityConflicts.length > 0 && (
<span className="font-medium text-amber-600 dark:text-amber-300">
{suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"}
</span>
)}
</div>
</div>
))}
</div>
)}
{!submitted && (
<div className="app-surface-strong border-dashed py-20 text-center">
<div className="mx-auto max-w-md">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">No suggestions yet</div>
<p className="mt-2 text-sm text-gray-500">
Add the required skills and date range, then run the search to see ranked staffing matches.
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
@@ -164,15 +207,15 @@ export function StaffingPanel() {
function ScoreBar({ label, value }: { label: string; value: number }) {
return (
<div>
<div className="text-xs text-gray-500 mb-1">{label}</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">{label}</div>
<div className="h-2 rounded-full bg-gray-200/80 dark:bg-gray-800">
<div
className="h-full bg-brand-500 rounded-full"
className="h-full rounded-full bg-brand-500"
style={{ width: `${value}%` }}
/>
</div>
<div className="text-xs text-gray-600 mt-0.5">{value}</div>
<div className="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">{value}</div>
</div>
);
}
@@ -1,7 +1,8 @@
"use client";
import { clsx } from "clsx";
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { trpc } from "~/lib/trpc/client.js";
export interface TimelineFilters {
@@ -38,6 +39,7 @@ export const DEFAULT_FILTERS: TimelineFilters = {
};
interface TimelineFilterProps {
anchorRef: RefObject<HTMLDivElement | null>;
filters: TimelineFilters;
onChange: (filters: TimelineFilters) => void;
isOpen: boolean;
@@ -48,12 +50,12 @@ interface TimelineFilterProps {
function Chip({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-brand-50 border border-brand-200 text-brand-700 rounded-full text-xs font-medium">
<span className="inline-flex items-center gap-1 rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:border-brand-800 dark:bg-brand-950/40 dark:text-brand-200">
{label}
<button
type="button"
onClick={onRemove}
className="text-brand-400 hover:text-brand-700 leading-none"
className="leading-none text-brand-400 hover:text-brand-700 dark:hover:text-brand-100"
>
×
</button>
@@ -79,7 +81,9 @@ function EidPicker({
{ staleTime: 15_000 },
);
type ResourceRow = { id: string; eid: string; displayName: string; chapter: string | null };
const suggestions = (data?.resources as ResourceRow[] | undefined ?? []).filter((r) => !selectedEids.includes(r.eid));
const suggestions = ((data?.resources as ResourceRow[] | undefined) ?? []).filter(
(r) => !selectedEids.includes(r.eid),
);
function add(eid: string) {
onChange([...selectedEids, eid]);
@@ -104,26 +108,38 @@ function EidPicker({
type="text"
placeholder="Search by name or EID…"
value={search}
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
className="app-input px-2.5 py-1.5 text-xs"
/>
{open && suggestions.length > 0 && (
<div
className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg max-h-40 overflow-y-auto"
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-40 overflow-y-auto rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
onMouseDown={(e) => e.preventDefault()}
>
{suggestions.map((r) => (
<button
key={r.id}
type="button"
onMouseDown={(e) => { e.preventDefault(); add(r.eid); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 flex items-center gap-2"
onMouseDown={(e) => {
e.preventDefault();
add(r.eid);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span className="font-mono text-gray-500 w-16 flex-shrink-0">{r.eid}</span>
<span className="text-gray-800 truncate">{r.displayName}</span>
{r.chapter && <span className="text-gray-400 flex-shrink-0">{r.chapter}</span>}
<span className="w-16 flex-shrink-0 font-mono text-gray-500 dark:text-gray-400">
{r.eid}
</span>
<span className="truncate text-gray-800 dark:text-gray-100">{r.displayName}</span>
{r.chapter && (
<span className="flex-shrink-0 text-gray-400 dark:text-gray-500">
{r.chapter}
</span>
)}
</button>
))}
</div>
@@ -146,19 +162,17 @@ function ProjectPicker({
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data } = trpc.project.list.useQuery(
{ search, limit: 200 },
{ staleTime: 15_000 },
);
const { data } = trpc.project.list.useQuery({ search, limit: 200 }, { staleTime: 15_000 });
type ProjectRow = { id: string; shortCode: string; name: string };
const suggestions = (data?.projects as ProjectRow[] | undefined ?? []).filter((p) => !selectedIds.includes(p.id));
const suggestions = ((data?.projects as ProjectRow[] | undefined) ?? []).filter(
(p) => !selectedIds.includes(p.id),
);
// Labels for selected chips — need to resolve names
const { data: allData } = trpc.project.list.useQuery(
{ limit: 500 },
{ staleTime: 60_000 },
const { data: allData } = trpc.project.list.useQuery({ limit: 500 }, { staleTime: 60_000 });
const projectMap = new Map(
((allData?.projects as ProjectRow[] | undefined) ?? []).map((p) => [p.id, p]),
);
const projectMap = new Map((allData?.projects as ProjectRow[] | undefined ?? []).map((p) => [p.id, p]));
function add(id: string) {
onChange([...selectedIds, id]);
@@ -175,13 +189,7 @@ function ProjectPicker({
<div className="flex flex-wrap gap-1 mb-1.5">
{selectedIds.map((id) => {
const p = projectMap.get(id);
return (
<Chip
key={id}
label={p ? p.name : id}
onRemove={() => remove(id)}
/>
);
return <Chip key={id} label={p ? p.name : id} onRemove={() => remove(id)} />;
})}
</div>
<div className="relative">
@@ -190,24 +198,30 @@ function ProjectPicker({
type="text"
placeholder="Search projects…"
value={search}
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
className="app-input px-2.5 py-1.5 text-xs"
/>
{open && suggestions.length > 0 && (
<div
className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg max-h-40 overflow-y-auto"
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-40 overflow-y-auto rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
onMouseDown={(e) => e.preventDefault()}
>
{suggestions.map((p) => (
<button
key={p.id}
type="button"
onMouseDown={(e) => { e.preventDefault(); add(p.id); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 flex items-center gap-2"
onMouseDown={(e) => {
e.preventDefault();
add(p.id);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span className="text-gray-800 truncate">{p.name}</span>
<span className="truncate text-gray-800 dark:text-gray-100">{p.name}</span>
</button>
))}
</div>
@@ -219,44 +233,115 @@ function ProjectPicker({
// ─── Main filter panel ────────────────────────────────────────────────────────
export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineFilterProps) {
export function TimelineFilter({
anchorRef,
filters,
onChange,
isOpen,
onClose,
}: TimelineFilterProps) {
const { data: resourceData } = trpc.resource.list.useQuery({ isActive: true, limit: 500 });
const panelRef = useRef<HTMLDivElement | null>(null);
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0 });
const chapters = [
...new Set(
(resourceData?.resources as Array<{ chapter: string | null }> | undefined ?? []).map((r) => r.chapter).filter(Boolean) as string[],
((resourceData?.resources as Array<{ chapter: string | null }> | undefined) ?? [])
.map((r) => r.chapter)
.filter(Boolean) as string[],
),
].sort();
const updatePanelPosition = useCallback(() => {
const trigger = anchorRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? 320;
const viewportPadding = 16;
const maxLeft = window.innerWidth - panelWidth - viewportPadding;
setPanelPosition({
top: rect.bottom + 8,
left: Math.max(viewportPadding, Math.min(rect.right - panelWidth, maxLeft)),
});
}, [anchorRef]);
useEffect(() => {
if (!isOpen) return;
updatePanelPosition();
const rafId = window.requestAnimationFrame(updatePanelPosition);
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (anchorRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
onClose();
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
window.addEventListener("mousedown", handlePointerDown);
window.addEventListener("keydown", handleEscape);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
window.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("keydown", handleEscape);
};
}, [anchorRef, isOpen, onClose, updatePanelPosition]);
if (!isOpen) return null;
const activeCount =
filters.chapters.length + filters.eids.length + filters.projectIds.length;
const activeCount = filters.chapters.length + filters.eids.length + filters.projectIds.length;
return (
<div className="absolute right-0 top-12 z-30 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl w-80 p-4">
<div className="flex items-center justify-between mb-4">
return createPortal(
<div
ref={panelRef}
style={{ position: "fixed", top: panelPosition.top, left: panelPosition.left }}
className="z-[9998] w-80 rounded-2xl border border-gray-200 bg-white p-4 shadow-xl dark:border-gray-700 dark:bg-gray-900"
>
<div className="mb-4 flex items-center justify-between">
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
Filters
{activeCount > 0 && (
<span className="ml-2 text-xs font-normal text-brand-600">{activeCount} active</span>
<span className="ml-2 text-xs font-normal text-brand-600 dark:text-brand-300">
{activeCount} active
</span>
)}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
</button>
</div>
{/* Zoom level */}
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Zoom</label>
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
Zoom
</label>
<div className="flex gap-2">
{(["day", "week", "month"] as const).map((z) => (
<button
type="button"
key={z}
onClick={() => onChange({ ...filters, zoom: z })}
className={clsx(
"flex-1 px-2 py-1.5 text-xs rounded-lg border capitalize",
"flex-1 rounded-xl border px-2 py-1.5 text-xs capitalize transition-colors",
filters.zoom === z
? "bg-brand-50 border-brand-300 text-brand-700"
: "border-gray-200 text-gray-600 hover:bg-gray-50",
? "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-700 dark:bg-brand-950/40 dark:text-brand-200"
: "border-gray-300 text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800",
)}
>
{z}
@@ -267,7 +352,7 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
{/* EID filter */}
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
People (EID)
</label>
<EidPicker
@@ -278,7 +363,7 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
{/* Project filter */}
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
Projects
</label>
<ProjectPicker
@@ -290,12 +375,15 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
{/* Chapters */}
{chapters.length > 0 && (
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
Chapters
</label>
<div className="space-y-1 max-h-32 overflow-y-auto">
<div className="max-h-32 space-y-1 overflow-y-auto rounded-xl border border-gray-200 p-2 dark:border-gray-700">
{chapters.map((ch) => (
<label key={ch} className="flex items-center gap-2 text-sm cursor-pointer">
<label
key={ch}
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
>
<input
type="checkbox"
checked={filters.chapters.includes(ch)}
@@ -305,7 +393,7 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
: filters.chapters.filter((c) => c !== ch);
onChange({ ...filters, chapters: next });
}}
className="rounded border-gray-300"
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">{ch}</span>
</label>
@@ -316,73 +404,92 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
{/* Visibility toggles */}
<div className="mb-4 space-y-2">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Visibility</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
Visibility
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={filters.showWeekends}
onChange={(e) => onChange({ ...filters, showWeekends: e.target.checked })}
className="rounded border-gray-300"
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">Show weekends</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={!filters.hideCompletedProjects}
onChange={(e) => onChange({ ...filters, hideCompletedProjects: !e.target.checked })}
className="rounded border-gray-300 mt-0.5"
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">
Show completed &amp; cancelled
<span className="block text-xs text-gray-400 font-normal">Default set in Preferences</span>
<span className="block text-xs text-gray-400 font-normal">
Default set in Preferences
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={filters.showDrafts}
onChange={(e) => onChange({ ...filters, showDrafts: e.target.checked })}
className="rounded border-gray-300 mt-0.5"
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">
Show draft projects
<span className="block text-xs text-gray-400 font-normal">Shows PROPOSED allocations</span>
<span className="block text-xs text-gray-400 font-normal">
Shows PROPOSED allocations
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={filters.showVacations}
onChange={(e) => onChange({ ...filters, showVacations: e.target.checked })}
className="rounded border-gray-300 mt-0.5"
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">
Show vacation blocks
<span className="block text-xs text-gray-400 font-normal">Approved leave on resource rows</span>
<span className="block text-xs text-gray-400 font-normal">
Approved leave on resource rows
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={filters.showPlaceholders}
onChange={(e) => onChange({ ...filters, showPlaceholders: e.target.checked })}
className="rounded border-gray-300 mt-0.5"
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">
Show open demand
<span className="block text-xs text-gray-400 font-normal">Dashed bars for unassigned staffing demand</span>
<span className="block text-xs text-gray-400 font-normal">
Dashed bars for unassigned staffing demand
</span>
</span>
</label>
</div>
<button
type="button"
onClick={() => onChange(DEFAULT_FILTERS)}
disabled={activeCount === 0 && !filters.showWeekends && filters.hideCompletedProjects && !filters.showDrafts && filters.showVacations && filters.showPlaceholders}
className="w-full text-xs text-gray-500 hover:text-gray-700 underline disabled:opacity-40 disabled:no-underline"
disabled={
activeCount === 0 &&
!filters.showWeekends &&
filters.hideCompletedProjects &&
!filters.showDrafts &&
filters.showVacations &&
filters.showPlaceholders
}
className="w-full text-xs text-gray-500 underline transition hover:text-gray-700 disabled:no-underline disabled:opacity-40 dark:text-gray-400 dark:hover:text-gray-200"
>
Reset all filters
</button>
</div>
</div>,
document.body,
);
}
@@ -1,6 +1,7 @@
"use client";
import { clsx } from "clsx";
import { useRef } from "react";
import { TimelineFilter, type TimelineFilters } from "./TimelineFilter.js";
interface TimelineToolbarProps {
@@ -40,35 +41,40 @@ export function TimelineToolbar({
onUndo,
onRedo,
}: TimelineToolbarProps) {
const activeFilterCount = filters.chapters.length + filters.eids.length + filters.projectIds.length;
const activeFilterCount =
filters.chapters.length + filters.eids.length + filters.projectIds.length;
const filterAnchorRef = useRef<HTMLDivElement | null>(null);
return (
<div className="flex items-center justify-between mb-2 gap-3">
<div className="text-sm text-gray-500">
<div className="app-toolbar flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-gray-500 dark:text-gray-400">
{viewMode === "resource"
? `${resourceCount} resources · ${totalAllocCount} allocations`
: `${projectCount} projects`}
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
{/* Timeline navigation */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={onNavigateBack}
className="px-2.5 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
title="Previous 4 weeks"
>
</button>
<button
type="button"
onClick={onNavigateToday}
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
Today
</button>
<button
type="button"
onClick={onNavigateForward}
className="px-2.5 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
title="Next 4 weeks"
>
@@ -79,18 +85,20 @@ export function TimelineToolbar({
{(onUndo ?? onRedo) && (
<div className="flex items-center gap-1">
<button
type="button"
onClick={onUndo}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
className="px-2 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
</button>
<button
type="button"
onClick={onRedo}
disabled={!canRedo}
title="Redo (Ctrl+Shift+Z / Ctrl+Y)"
className="px-2 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
</button>
@@ -98,25 +106,27 @@ export function TimelineToolbar({
)}
{/* View mode toggle */}
<div className="flex rounded-lg border border-gray-200 overflow-hidden text-sm">
<div className="flex overflow-hidden rounded-xl border border-gray-300 bg-white text-sm dark:border-gray-600 dark:bg-gray-900">
<button
type="button"
onClick={() => onViewModeChange("resource")}
className={clsx(
"px-3 py-1.5 transition-colors",
"px-3 py-2 transition-colors",
viewMode === "resource"
? "bg-brand-600 text-white"
: "text-gray-600 hover:bg-gray-50",
: "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
)}
>
Resource view
</button>
<button
type="button"
onClick={() => onViewModeChange("project")}
className={clsx(
"px-3 py-1.5 border-l border-gray-200 transition-colors",
"border-l border-gray-300 px-3 py-2 transition-colors dark:border-gray-600",
viewMode === "project"
? "bg-brand-600 text-white"
: "text-gray-600 hover:bg-gray-50",
: "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
)}
>
Project view
@@ -124,24 +134,26 @@ export function TimelineToolbar({
</div>
{/* Filter */}
<div className="relative">
<div ref={filterAnchorRef} className="relative">
<button
type="button"
onClick={() => onFilterOpenChange(!filterOpen)}
className={clsx(
"flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg border transition-colors",
"flex items-center gap-2 rounded-xl border px-3 py-2 text-sm transition-colors",
filterOpen || activeFilterCount > 0
? "bg-brand-50 border-brand-300 text-brand-700"
: "border-gray-200 text-gray-600 hover:bg-gray-50",
? "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-700 dark:bg-brand-950/40 dark:text-brand-200"
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800",
)}
>
Filter
{activeFilterCount > 0 && (
<span className="w-4 h-4 rounded-full bg-brand-600 text-white text-xs flex items-center justify-center">
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-brand-600 text-xs text-white">
{activeFilterCount}
</span>
)}
</button>
<TimelineFilter
anchorRef={filterAnchorRef}
filters={filters}
onChange={onFiltersChange}
isOpen={filterOpen}
@@ -14,11 +14,7 @@ import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
import { TimelineHeader } from "./TimelineHeader.js";
import { TimelineToolbar } from "./TimelineToolbar.js";
import { addDays } from "./utils.js";
import {
HEADER_DAY_HEIGHT,
HEADER_MONTH_HEIGHT,
LABEL_WIDTH,
} from "./timelineConstants.js";
import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js";
import { formatDateShort } from "~/lib/format.js";
import {
TimelineProvider,
@@ -40,11 +36,18 @@ export function TimelineView() {
pushHistoryRef.current = pushHistory;
const [popover, setPopover] = useState<{
allocationId: string; projectId: string; x: number; y: number;
allocationId: string;
projectId: string;
x: number;
y: number;
} | null>(null);
const [newAllocPopover, setNewAllocPopover] = useState<{
resourceId: string; startDate: Date; endDate: Date;
suggestedProjectId: string | null; anchorX: number; anchorY: number;
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
} | null>(null);
// cellWidth placeholder — the real value comes from useTimelineLayout inside the content.
@@ -53,12 +56,24 @@ export function TimelineView() {
const cellWidthRef = useRef(40);
const {
dragState, allocDragState, rangeState,
shiftPreview, isPreviewLoading, isApplying, isAllocSaving,
onProjectBarMouseDown, onAllocMouseDown, onRowMouseDown,
onCanvasMouseMove, onCanvasMouseUp, onCanvasMouseLeave,
onProjectBarTouchStart, onAllocTouchStart, onRowTouchStart,
onCanvasTouchMove, onCanvasTouchEnd,
dragState,
allocDragState,
rangeState,
shiftPreview,
isPreviewLoading,
isApplying,
isAllocSaving,
onProjectBarMouseDown,
onAllocMouseDown,
onRowMouseDown,
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
onProjectBarTouchStart,
onAllocTouchStart,
onRowTouchStart,
onCanvasTouchMove,
onCanvasTouchEnd,
} = useTimelineDrag({
cellWidth: cellWidthRef.current,
onBlockClick: (info) => {
@@ -189,7 +204,14 @@ function TimelineViewContent({
contextResourceIds: string[];
popover: { allocationId: string; projectId: string; x: number; y: number } | null;
setPopover: React.Dispatch<React.SetStateAction<typeof popover>>;
newAllocPopover: { resourceId: string; startDate: Date; endDate: Date; suggestedProjectId: string | null; anchorX: number; anchorY: number } | null;
newAllocPopover: {
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
} | null;
setNewAllocPopover: React.Dispatch<React.SetStateAction<typeof newAllocPopover>>;
openPanelProjectId: string | null;
setOpenPanelProjectId: React.Dispatch<React.SetStateAction<string | null>>;
@@ -231,13 +253,8 @@ function TimelineViewContent({
const [openDemandToAssign, setOpenDemandToAssign] = useState<OpenDemandAssignment | null>(null);
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } = useTimelineLayout(
viewStart,
viewDays,
filters.zoom,
filters.showWeekends,
today,
);
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } =
useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today);
// Keep cellWidthRef in sync so the drag hook uses the correct value.
cellWidthRef.current = CELL_WIDTH;
@@ -295,8 +312,14 @@ function TimelineViewContent({
const isMac = navigator.platform.toUpperCase().includes("MAC");
const modKey = isMac ? e.metaKey : e.ctrlKey;
if (!modKey) return;
if (e.key === "z" && !e.shiftKey) { e.preventDefault(); void undo(); }
if ((e.key === "z" && e.shiftKey) || e.key === "y") { e.preventDefault(); void redo(); }
if (e.key === "z" && !e.shiftKey) {
e.preventDefault();
void undo();
}
if ((e.key === "z" && e.shiftKey) || e.key === "y") {
e.preventDefault();
void redo();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
@@ -317,8 +340,7 @@ function TimelineViewContent({
};
return (
<div className="flex-1 flex flex-col min-h-0 relative mt-2 mx-4 mb-4">
<div className="relative flex flex-1 flex-col gap-4 min-h-0">
{/* Toolbar */}
<TimelineToolbar
viewMode={viewMode}
@@ -335,23 +357,26 @@ function TimelineViewContent({
onNavigateForward={() => setViewStart((v) => addDays(v, 28))}
canUndo={canUndo}
canRedo={canRedo}
onUndo={() => { void undo(); }}
onRedo={() => { void redo(); }}
onUndo={() => {
void undo();
}}
onRedo={() => {
void redo();
}}
/>
{/* Scrollable canvas */}
<div
ref={scrollContainerRef}
onScroll={handleContainerScroll}
className="flex-1 overflow-auto border border-gray-200 rounded-xl bg-white"
className="app-surface relative flex-1 overflow-auto"
>
{isInitialLoading ? (
<div className="flex items-center justify-center py-20 text-gray-400">
<div className="flex items-center justify-center py-24 text-sm text-gray-500 dark:text-gray-400">
Loading timeline...
</div>
) : (
<div style={{ minWidth: LABEL_WIDTH + totalCanvasWidth }}>
<TimelineHeader
monthGroups={monthGroups}
dates={dates}
@@ -370,7 +395,9 @@ function TimelineViewContent({
onMouseMove={handleMouseMove}
onMouseUp={(e) => void onCanvasMouseUp(e)}
onMouseLeave={onCanvasMouseLeave}
onTouchMove={(e) => { onCanvasTouchMove(e); }}
onTouchMove={(e) => {
onCanvasTouchMove(e);
}}
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
onContextMenu={(e) => e.preventDefault()}
className={clsx(
@@ -423,15 +450,14 @@ function TimelineViewContent({
/>
)}
</div>
</div>
)}
</div>
{/* Saving indicators */}
{(isApplying || isAllocSaving) && (
<div className="absolute inset-0 bg-white/40 flex items-center justify-center z-50 rounded-xl pointer-events-none">
<div className="bg-white border border-gray-200 rounded-xl px-5 py-3 shadow-xl text-sm font-medium text-gray-700">
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
<div className="app-surface px-5 py-3 text-sm font-medium text-gray-700 dark:text-gray-200">
{isApplying ? "Applying shift…" : "Saving…"}
</div>
</div>
@@ -445,10 +471,17 @@ function TimelineViewContent({
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 8 }}
>
<ShiftPreviewTooltip
preview={shiftPreview ?? {
valid: true, deltaCents: 0, wouldExceedBudget: false,
budgetUtilizationAfter: 0, conflictCount: 0, errors: [], warnings: [],
}}
preview={
shiftPreview ?? {
valid: true,
deltaCents: 0,
wouldExceedBudget: false,
budgetUtilizationAfter: 0,
conflictCount: 0,
errors: [],
warnings: [],
}
}
projectName={dragState.projectName ?? ""}
newStartDate={dragState.currentStartDate ?? today}
newEndDate={dragState.currentEndDate ?? today}
@@ -458,20 +491,23 @@ function TimelineViewContent({
)}
{/* Alloc drag tooltip */}
{allocDragState.isActive && allocDragState.daysDelta !== 0 && allocDragState.currentStartDate && allocDragState.currentEndDate && (
<div
ref={allocTooltipRef}
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
<div className="font-semibold">{allocDragState.projectName}</div>
<div className="opacity-80">
{formatDateShort(allocDragState.currentStartDate)}
{" "}
{formatDateShort(allocDragState.currentEndDate)}
{allocDragState.isActive &&
allocDragState.daysDelta !== 0 &&
allocDragState.currentStartDate &&
allocDragState.currentEndDate && (
<div
ref={allocTooltipRef}
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
<div className="font-semibold">{allocDragState.projectName}</div>
<div className="opacity-80">
{formatDateShort(allocDragState.currentStartDate)}
{" "}
{formatDateShort(allocDragState.currentEndDate)}
</div>
</div>
</div>
)}
)}
{/* Range-select hint */}
{rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && (
@@ -482,9 +518,10 @@ function TimelineViewContent({
>
{(() => {
const end = rangeState.currentDate;
const [s, e] = rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
const [s, e] =
rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
const days = Math.round((e.getTime() - s.getTime()) / 86400000) + 1;
return `${days} day${days !== 1 ? "s" : ""}`;
})()}
@@ -497,7 +534,10 @@ function TimelineViewContent({
allocationId={popover.allocationId}
projectId={popover.projectId}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => { setPopover(null); setOpenPanelProjectId(pid); }}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
/>
@@ -519,10 +559,7 @@ function TimelineViewContent({
{/* Project side panel */}
{openPanelProjectId && (
<ProjectPanel
projectId={openPanelProjectId}
onClose={() => setOpenPanelProjectId(null)}
/>
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
)}
{/* Open-demand assignment modal */}
+2 -2
View File
@@ -8,13 +8,13 @@ interface FilterBarProps {
export function FilterBar({ children, hasActiveFilters, onClearFilters }: FilterBarProps) {
return (
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="app-toolbar mb-4 flex flex-wrap items-center gap-3">
{children}
{hasActiveFilters && onClearFilters && (
<button
type="button"
onClick={onClearFilters}
className="px-3 py-2 text-sm text-gray-500 hover:text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
className="rounded-xl border border-gray-300 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-400 hover:bg-gray-50 hover:text-gray-900 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
>
Clear filters
</button>
@@ -54,10 +54,10 @@ export function NavProgressBar() {
return (
<div
aria-hidden="true"
className="fixed top-0 left-0 right-0 z-[9999] h-0.5 pointer-events-none"
className="pointer-events-none fixed left-0 right-0 top-0 z-[9999] h-1"
>
<div
className="h-full bg-brand-500 transition-all ease-out"
className="h-full rounded-r-full bg-gradient-to-r from-brand-400 via-brand-500 to-brand-600 shadow-[0_0_18px_rgba(var(--accent-500),0.45)] transition-all ease-out"
style={{
width: `${width}%`,
transitionDuration: width === 100 ? "200ms" : "400ms",
+15 -3
View File
@@ -12,6 +12,15 @@ function getBaseUrl() {
return `http://localhost:${process.env["PORT"] ?? 3100}`;
}
function isIgnorableTransportError(error: unknown): boolean {
const message =
typeof error === "object" && error !== null && "message" in error
? String((error as { message?: unknown }).message ?? "")
: "";
return message.includes("Failed to fetch") || message.toLowerCase().includes("aborted");
}
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
@@ -29,9 +38,12 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) {
trpc.createClient({
links: [
loggerLink({
enabled: (opts) =>
process.env["NODE_ENV"] === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
enabled: (opts) => {
const isDownError = opts.direction === "down" && isIgnorableTransportError(opts.result);
if (isDownError) return false;
if (process.env["NODE_ENV"] === "development") return true;
return opts.direction === "down";
},
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
+2 -1
View File
@@ -20,7 +20,8 @@ const config: Config = {
},
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
sans: ["var(--font-ui)", "system-ui", "sans-serif"],
display: ["var(--font-display)", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "monospace"],
},
},
+49
View File
@@ -0,0 +1,49 @@
# Departed Sync Review
Date: 2026-03-14
Source workbook:
- `/home/hartmut/Documents/Copilot/planarchy/samples/Dispov2/MV_DispoRoster.xlsx`
- Sheet: `DispoRoster`
- Column K: `MV Ressource Type`
Applied rule:
- If column K equals `Departed`, set `resource.departed = true` for the matching Planarchy resource identified by `EID`.
Result:
- Workbook rows marked `Departed`: `166`
- Matching resources found in Planarchy: `141`
- Resources updated to `departed = true`: `141`
- Workbook EIDs not found in Planarchy: `25`
Missing EIDs:
- `antonia.melzer`
- `ashutosh.pandit`
- `augusto.a.wagner`
- `c.guillen.aguilera`
- `c.muelstegen`
- `christian.reimann`
- `christian.zajac`
- `christophe.metz`
- `e.alvarado.tenorio`
- `e.chinchilla.fallas`
- `e.navarro.gandia`
- `eilin.pham`
- `franz.csmarits`
- `Jiaqi.a.jin`
- `josephine.grimm`
- `panagiotis.makrygiannis`
- `philipp.brueckner`
- `rohit.raut`
- `roque.vasquez`
- `s.baswaraj.biradar`
- `s.chacon.fonseca`
- `s.gardzielewski`
- `s.khisamutdinova`
- `ulrich.noerenberg`
- `yvonne.karle`
Note on `rolledOff`:
- No explicit rolled-off source column was found in this workbook.
- `Last day in dispo`, `Resource Hours/Week = 0`, `Long-term absence`, and `Demand` are not safe equivalents for `rolledOff`.
- Do not bulk-update `rolledOff` from this file without an explicit business rule.
+5 -1
View File
@@ -34,7 +34,7 @@ The following items were proposed in older markdown files and are already implem
| Workstream | Status | Why It Is Still Open | Recommended Next Step |
|---|---|---|---|
| Estimating system | `In progress` | Full CRUD, versioning, export, planning handoff, clone/template, rate cards per client, and richer version comparison (scope item diffs, resource snapshot diffs, chapter subtotals, margin %). Remaining gaps: scope-to-effort rule engine, experience multipliers, weekly phasing, structured commercial terms. | Scope-to-effort rule engine for auto-expanding scope items into demand lines per discipline. |
| Estimating system | `Complete` | Full CRUD, versioning, export, planning handoff, clone/template, rate cards per client, richer version comparison, scope-to-effort rule engine, experience multipliers & shoring ratios, weekly phasing (4Dispo grid), and structured commercial terms. | — |
| Demand vs assignment split | `Complete` | Legacy `Allocation` table dropped. `legacyAllocationId` columns removed. Migration tooling deleted. Compatibility facades renamed to clean domain names (`updateAllocationEntry`, `deleteAllocationEntry`, `fillOpenDemand`, `loadAllocationEntry`). `isPlaceholder` is a derived read-model property. No legacy compatibility naming remains. | — |
| Widget platform refactor | `Implemented` | Widget config typing, layout normalization, registry-driven rendering, and dashboard query extraction now live behind shared/application contracts instead of ad hoc router logic. | Keep future widgets on the same registry + application-use-case pattern. |
| Package-level regression tests | `Expanded` | Shared schema validation (rate-card, allocation, estimate — 32 tests), engine vacation/recurrence (29 tests), staffing capacity-analyzer (12 tests), plus existing application and dashboard tests. | Continue adding API-level integration tests for remaining router procedures. |
@@ -81,6 +81,10 @@ Current state:
- rate cards per client with admin management UI
- richer version comparison engine: per-item scope diffs with field-level change detection, resource snapshot rate/location diffs, chapter-grouped subtotals sorted by cost impact, margin % delta
- version comparison UI: scope item detail table, resource rate diff table, chapter subtotals section, margin summary card
- scope-to-effort rule engine: admin CRUD for rule sets (per_frame/per_item/flat unit modes), estimate workspace preview + apply with replace/append mode, engine with 16 tests
- experience multipliers & shoring ratios: hierarchical specificity matching (chapter/location/level), rate multipliers, shoring ratio + additional effort factor, admin CRUD, estimate workspace preview + apply, engine with 23 tests
- weekly phasing (4Dispo grid): ISO week-based phasing with even/front-loaded/back-loaded patterns, chapter aggregation view, heat-map coloring, estimate workspace phasing tab, engine with 30 tests
- structured commercial terms: pricing model (fixed/T&M/hybrid), contingency %, discount %, payment term days, warranty months, payment milestones with validation and amount computation, integrated into financials tab, engine with 19 tests
### 2. Refactor the planning core before v2 migration
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from "vitest";
import { getAnonymizationDirectory } from "../lib/anonymization.js";
describe("anonymization directory", () => {
it("persists aliases so existing resources keep the same identity when new resources appear", async () => {
let storedAliases: Record<string, { displayName: string; eid: string }> = {
resource_a: {
displayName: "Iron Man",
eid: "iron.man",
},
};
const resourcesRoundOne = [
{
id: "resource_a",
eid: "alice",
displayName: "Alice",
email: "alice@example.com",
lcrCents: 14000,
},
{
id: "resource_b",
eid: "bob",
displayName: "Bob",
email: "bob@example.com",
lcrCents: 11000,
},
];
const resourcesRoundTwo = [
...resourcesRoundOne,
{
id: "resource_c",
eid: "carol",
displayName: "Carol",
email: "carol@example.com",
lcrCents: 12500,
},
];
const db = {
systemSettings: {
findUnique: vi.fn(async () => ({
anonymizationEnabled: true,
anonymizationDomain: "superhartmut.de",
anonymizationSeed: "stable-seed",
anonymizationMode: "global",
anonymizationAliases: storedAliases,
})),
update: vi.fn(async ({ data }: { data: { anonymizationAliases: typeof storedAliases } }) => {
storedAliases = data.anonymizationAliases;
return {};
}),
},
resource: {
findMany: vi
.fn()
.mockResolvedValueOnce(resourcesRoundOne)
.mockResolvedValueOnce(resourcesRoundTwo),
},
};
const firstDirectory = await getAnonymizationDirectory(db as never);
const secondDirectory = await getAnonymizationDirectory(db as never);
expect(firstDirectory?.byResourceId.get("resource_a")).toMatchObject({
displayName: "Iron Man",
eid: "iron.man",
email: "iron.man@superhartmut.de",
});
expect(firstDirectory?.byResourceId.get("resource_b")).toBeDefined();
expect(secondDirectory?.byResourceId.get("resource_a")).toMatchObject({
displayName: "Iron Man",
eid: "iron.man",
email: "iron.man@superhartmut.de",
});
expect(secondDirectory?.byResourceId.get("resource_b")).toEqual(
firstDirectory?.byResourceId.get("resource_b"),
);
expect(secondDirectory?.byResourceId.get("resource_c")).toBeDefined();
expect(db.systemSettings.update).toHaveBeenCalledTimes(2);
});
it("regenerates legacy aliases with digits into stable aliases without numeric characters", async () => {
let storedAliases: Record<string, { displayName: string; eid: string }> = {
resource_a: {
displayName: "Baloo 16",
eid: "baloo.16",
},
};
const db = {
systemSettings: {
findUnique: vi.fn(async () => ({
anonymizationEnabled: true,
anonymizationDomain: "superhartmut.de",
anonymizationSeed: "stable-seed",
anonymizationMode: "global",
anonymizationAliases: storedAliases,
})),
update: vi.fn(async ({ data }: { data: { anonymizationAliases: typeof storedAliases } }) => {
storedAliases = data.anonymizationAliases;
return {};
}),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_a",
eid: "alice",
displayName: "Alice",
email: "alice@example.com",
lcrCents: 6000,
},
]),
},
};
const directory = await getAnonymizationDirectory(db as never);
const alias = directory?.byResourceId.get("resource_a");
expect(alias).toBeDefined();
expect(alias?.displayName).not.toContain("16");
expect(alias?.eid).not.toContain("16");
expect(alias?.displayName).toMatch(/^[A-Za-z]+(?: [A-Za-z]+)*$/);
expect(alias?.eid).toMatch(/^[a-z]+(?:\.[a-z]+)*$/);
expect(db.systemSettings.update).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,207 @@
import { SystemRole } from "@planarchy/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@planarchy/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@planarchy/application")>();
return {
...actual,
isChargeabilityActualBooking: actual.isChargeabilityActualBooking,
listAssignmentBookings: vi.fn(),
};
});
import { listAssignmentBookings } from "@planarchy/application";
import { chargeabilityReportRouter } from "../router/chargeability-report.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(chargeabilityReportRouter);
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "controller@example.com", name: "Controller", image: null },
expires: "2026-03-14T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_controller",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
describe("chargeability report router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("excludes proposed bookings by default but includes them when requested", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_1",
eid: "E-001",
displayName: "Alice",
fte: 1,
chargeabilityTarget: 80,
country: {
id: "country_es",
code: "ES",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_1", name: "Barcelona" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{ id: "project_confirmed", utilizationCategory: { code: "Chg" } },
{ id: "project_proposed", utilizationCategory: { code: "Chg" } },
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_confirmed",
projectId: "project_confirmed",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "CONFIRMED",
project: {
id: "project_confirmed",
name: "Confirmed Project",
shortCode: "CP",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
{
id: "assignment_proposed",
projectId: "project_proposed",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "PROPOSED",
project: {
id: "project_proposed",
name: "Proposed Project",
shortCode: "PP",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const strict = await caller.getReport({
startMonth: "2026-03",
endMonth: "2026-03",
});
const withProposed = await caller.getReport({
startMonth: "2026-03",
endMonth: "2026-03",
includeProposed: true,
});
const strictMonth = strict.resources[0]?.months[0];
const proposedMonth = withProposed.resources[0]?.months[0];
expect(strictMonth).toBeDefined();
expect(proposedMonth).toBeDefined();
expect(strictMonth?.chg).toBeGreaterThan(0);
expect(proposedMonth?.chg).toBeGreaterThan(strictMonth?.chg ?? 0);
expect(proposedMonth?.chg).toBeCloseTo((strictMonth?.chg ?? 0) * 2, 5);
expect(withProposed.groupTotals[0]?.chg).toBeGreaterThan(strict.groupTotals[0]?.chg ?? 0);
});
it("includes imported TBD draft work only when proposed bookings are enabled", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_1",
eid: "E-001",
displayName: "Alice",
fte: 1,
chargeabilityTarget: 80,
country: {
id: "country_es",
code: "ES",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_1", name: "Barcelona" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{ id: "project_tbd", utilizationCategory: { code: "Chg" } },
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_tbd",
projectId: "project_tbd",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "PROPOSED",
project: {
id: "project_tbd",
name: "TBD Project",
shortCode: "TBD-P1",
status: "DRAFT",
orderType: "CLIENT",
dynamicFields: { dispoImport: { isTbd: true } },
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const strict = await caller.getReport({
startMonth: "2026-03",
endMonth: "2026-03",
});
const withProposed = await caller.getReport({
startMonth: "2026-03",
endMonth: "2026-03",
includeProposed: true,
});
expect(strict.resources[0]?.months[0]?.chg).toBe(0);
expect(withProposed.resources[0]?.months[0]?.chg).toBeGreaterThan(0);
expect(withProposed.groupTotals[0]?.chg).toBeGreaterThan(strict.groupTotals[0]?.chg ?? 0);
});
});
@@ -0,0 +1,540 @@
import { SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { effortRuleRouter } from "../router/effort-rule.js";
import { createCallerFactory } from "../trpc.js";
// Mock the engine — we focus on the router/DB layer, not the pure engine logic
vi.mock("@planarchy/engine", () => ({
expandScopeToEffort: vi.fn().mockReturnValue({
lines: [
{
scopeItemName: "Shot_001",
scopeType: "shot",
discipline: "Compositing",
chapter: null,
hours: 16,
unitMode: "per_frame" as const,
unitCount: 200,
hoursPerUnit: 0.08,
},
],
warnings: [],
unmatchedScopeItems: [],
}),
aggregateByDiscipline: vi.fn().mockReturnValue({
Compositing: { totalHours: 16, lineCount: 1 },
}),
}));
const createCaller = createCallerFactory(effortRuleRouter);
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "ctrl@example.com", name: "Controller", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_ctrl",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "mgr@example.com", name: "Manager", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_mgr",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
// ── Sample data factories ────────────────────────────────────────────────────
function sampleRuleSet(overrides: Record<string, unknown> = {}) {
return {
id: "ers_1",
name: "VFX Standard",
description: null,
isDefault: true,
createdAt: new Date(),
updatedAt: new Date(),
rules: [
{
id: "er_1",
ruleSetId: "ers_1",
scopeType: "shot",
discipline: "Compositing",
chapter: null,
unitMode: "per_frame",
hoursPerUnit: 0.08,
description: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
},
],
...overrides,
};
}
// ─── list ────────────────────────────────────────────────────────────────────
describe("effortRule.list", () => {
it("returns rule sets ordered by isDefault desc, name asc", async () => {
const sets = [sampleRuleSet(), sampleRuleSet({ id: "ers_2", name: "Animation", isDefault: false })];
const db = {
effortRuleSet: {
findMany: vi.fn().mockResolvedValue(sets),
},
};
const caller = createControllerCaller(db);
const result = await caller.list();
expect(result).toHaveLength(2);
expect(db.effortRuleSet.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
}),
);
});
});
// ─── getById ─────────────────────────────────────────────────────────────────
describe("effortRule.getById", () => {
it("returns the rule set when found", async () => {
const set = sampleRuleSet();
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(set),
},
};
const caller = createControllerCaller(db);
const result = await caller.getById({ id: "ers_1" });
expect(result.id).toBe("ers_1");
expect(result.rules).toHaveLength(1);
});
it("throws NOT_FOUND when rule set does not exist", async () => {
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createControllerCaller(db);
await expect(caller.getById({ id: "nonexistent" })).rejects.toThrow("Effort rule set not found");
});
});
// ─── create ──────────────────────────────────────────────────────────────────
describe("effortRule.create", () => {
it("creates a rule set with rules", async () => {
const created = sampleRuleSet();
const db = {
effortRuleSet: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue(created),
},
};
const caller = createManagerCaller(db);
const result = await caller.create({
name: "VFX Standard",
isDefault: false,
rules: [
{
scopeType: "shot",
discipline: "Compositing",
unitMode: "per_frame",
hoursPerUnit: 0.08,
sortOrder: 0,
},
],
});
expect(result.id).toBe("ers_1");
expect(db.effortRuleSet.create).toHaveBeenCalledTimes(1);
// isDefault was false, so updateMany should NOT have been called
expect(db.effortRuleSet.updateMany).not.toHaveBeenCalled();
});
it("unsets other defaults when creating a new default rule set", async () => {
const created = sampleRuleSet({ isDefault: true });
const db = {
effortRuleSet: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue(created),
},
};
const caller = createManagerCaller(db);
await caller.create({
name: "VFX Standard",
isDefault: true,
rules: [],
});
expect(db.effortRuleSet.updateMany).toHaveBeenCalledWith({
where: { isDefault: true },
data: { isDefault: false },
});
});
});
// ─── update ──────────────────────────────────────────────────────────────────
describe("effortRule.update", () => {
it("updates name and description without touching rules", async () => {
const existing = sampleRuleSet();
const updated = { ...existing, name: "VFX Updated" };
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(existing),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
update: vi.fn().mockResolvedValue(updated),
},
effortRule: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
};
const caller = createManagerCaller(db);
const result = await caller.update({ id: "ers_1", name: "VFX Updated" });
expect(result.name).toBe("VFX Updated");
// No rules provided, so rule replacement should not happen
expect(db.effortRule.deleteMany).not.toHaveBeenCalled();
expect(db.effortRule.createMany).not.toHaveBeenCalled();
});
it("replaces rules when rules array is provided", async () => {
const existing = sampleRuleSet();
const updated = sampleRuleSet({ name: "Updated" });
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(existing),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
update: vi.fn().mockResolvedValue(updated),
},
effortRule: {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
createMany: vi.fn().mockResolvedValue({ count: 2 }),
},
};
const caller = createManagerCaller(db);
await caller.update({
id: "ers_1",
rules: [
{ scopeType: "shot", discipline: "Lighting", unitMode: "per_frame", hoursPerUnit: 0.1, sortOrder: 0 },
{ scopeType: "asset", discipline: "Modeling", unitMode: "per_item", hoursPerUnit: 8, sortOrder: 1 },
],
});
expect(db.effortRule.deleteMany).toHaveBeenCalledWith({ where: { ruleSetId: "ers_1" } });
expect(db.effortRule.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({ ruleSetId: "ers_1", discipline: "Lighting" }),
expect.objectContaining({ ruleSetId: "ers_1", discipline: "Modeling" }),
]),
});
});
it("throws NOT_FOUND when rule set does not exist", async () => {
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(null),
updateMany: vi.fn(),
update: vi.fn(),
},
effortRule: { deleteMany: vi.fn(), createMany: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(caller.update({ id: "nonexistent", name: "X" })).rejects.toThrow("Effort rule set not found");
});
});
// ─── delete ──────────────────────────────────────────────────────────────────
describe("effortRule.delete", () => {
it("deletes the rule set and returns its id", async () => {
const existing = sampleRuleSet();
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(existing),
delete: vi.fn().mockResolvedValue(existing),
},
};
const caller = createManagerCaller(db);
const result = await caller.delete({ id: "ers_1" });
expect(result).toEqual({ id: "ers_1" });
expect(db.effortRuleSet.delete).toHaveBeenCalledWith({ where: { id: "ers_1" } });
});
it("throws NOT_FOUND when rule set does not exist", async () => {
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(null),
delete: vi.fn(),
},
};
const caller = createManagerCaller(db);
await expect(caller.delete({ id: "nonexistent" })).rejects.toThrow("Effort rule set not found");
});
});
// ─── preview ─────────────────────────────────────────────────────────────────
describe("effortRule.preview", () => {
it("returns expansion result with aggregation", async () => {
const estimate = {
id: "est_1",
baseCurrency: "EUR",
versions: [
{
id: "v_1",
versionNumber: 1,
status: "WORKING",
scopeItems: [
{
id: "si_1",
name: "Shot_001",
scopeType: "shot",
frameCount: 200,
itemCount: null,
unitMode: "per_frame",
sortOrder: 0,
},
],
},
],
};
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
};
const caller = createControllerCaller(db);
const result = await caller.preview({ estimateId: "est_1", ruleSetId: "ers_1" });
expect(result.lines).toHaveLength(1);
expect(result.scopeItemCount).toBe(1);
expect(result.ruleCount).toBe(1);
expect(result.aggregated).toBeDefined();
});
it("throws NOT_FOUND when estimate does not exist", async () => {
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(null) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(sampleRuleSet()) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "nope", ruleSetId: "ers_1" })).rejects.toThrow("Estimate not found");
});
it("throws NOT_FOUND when rule set does not exist", async () => {
const estimate = { id: "est_1", versions: [{ id: "v_1", scopeItems: [] }] };
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(null) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "est_1", ruleSetId: "nope" })).rejects.toThrow("Effort rule set not found");
});
it("throws NOT_FOUND when estimate has no versions", async () => {
const estimate = { id: "est_1", versions: [] };
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "est_1", ruleSetId: "ers_1" })).rejects.toThrow("Estimate has no versions");
});
});
// ─── applyRules ──────────────────────────────────────────────────────────────
describe("effortRule.applyRules", () => {
function makeEstimate(versionStatus: string, demandLines: unknown[] = []) {
return {
id: "est_1",
baseCurrency: "EUR",
versions: [
{
id: "v_1",
versionNumber: 1,
status: versionStatus,
scopeItems: [
{
id: "si_1",
name: "Shot_001",
scopeType: "shot",
frameCount: 200,
itemCount: null,
unitMode: "per_frame",
sortOrder: 0,
},
],
demandLines,
},
],
};
}
it("replaces existing demand lines in replace mode", async () => {
const estimate = makeEstimate("WORKING", [{ id: "dl_old" }]);
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
createMany: vi.fn().mockResolvedValue({ count: 1 }),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
const result = await caller.applyRules({
estimateId: "est_1",
ruleSetId: "ers_1",
mode: "replace",
});
expect(result.linesGenerated).toBe(1);
expect(db.estimateDemandLine.deleteMany).toHaveBeenCalledWith({
where: { estimateVersionId: "v_1" },
});
expect(db.estimateDemandLine.createMany).toHaveBeenCalledTimes(1);
expect(db.auditLog.create).toHaveBeenCalledTimes(1);
});
it("does not delete existing lines in append mode", async () => {
const estimate = makeEstimate("WORKING", [{ id: "dl_old" }]);
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: {
deleteMany: vi.fn(),
createMany: vi.fn().mockResolvedValue({ count: 1 }),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
const result = await caller.applyRules({
estimateId: "est_1",
ruleSetId: "ers_1",
mode: "append",
});
expect(result.linesGenerated).toBe(1);
expect(db.estimateDemandLine.deleteMany).not.toHaveBeenCalled();
});
it("rejects applying to a non-WORKING version", async () => {
const estimate = makeEstimate("SUBMITTED");
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: { deleteMany: vi.fn(), createMany: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "est_1", ruleSetId: "ers_1", mode: "replace" }),
).rejects.toThrow("Can only apply rules to a WORKING version");
});
it("throws NOT_FOUND when estimate does not exist", async () => {
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(null) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(sampleRuleSet()) },
estimateDemandLine: { deleteMany: vi.fn(), createMany: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "nope", ruleSetId: "ers_1", mode: "replace" }),
).rejects.toThrow("Estimate not found");
});
it("throws NOT_FOUND when estimate has no versions", async () => {
const estimate = { id: "est_1", baseCurrency: "EUR", versions: [] };
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(sampleRuleSet()) },
estimateDemandLine: { deleteMany: vi.fn(), createMany: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "est_1", ruleSetId: "ers_1", mode: "replace" }),
).rejects.toThrow("Estimate has no versions");
});
it("creates demand lines with correct metadata shape", async () => {
const estimate = makeEstimate("WORKING");
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 1 }),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
await caller.applyRules({ estimateId: "est_1", ruleSetId: "ers_1", mode: "replace" });
const createManyArg = db.estimateDemandLine.createMany.mock.calls[0][0];
const firstLine = createManyArg.data[0];
expect(firstLine.estimateVersionId).toBe("v_1");
expect(firstLine.lineType).toBe("LABOR");
expect(firstLine.currency).toBe("EUR");
expect(firstLine.costRateCents).toBe(0);
expect(firstLine.billRateCents).toBe(0);
expect(firstLine.metadata).toEqual(
expect.objectContaining({
effortRule: expect.objectContaining({
ruleSetId: "ers_1",
ruleSetName: "VFX Standard",
}),
}),
);
});
});
@@ -0,0 +1,443 @@
import { SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { entitlementRouter } from "../router/entitlement.js";
import { createCallerFactory } from "../trpc.js";
// Mock @planarchy/db to provide the enums used in the router
vi.mock("@planarchy/db", () => ({
VacationType: { ANNUAL: "ANNUAL", SICK: "SICK", OTHER: "OTHER", PUBLIC_HOLIDAY: "PUBLIC_HOLIDAY" },
VacationStatus: { APPROVED: "APPROVED", PENDING: "PENDING", REJECTED: "REJECTED" },
}));
const createCaller = createCallerFactory(entitlementRouter);
// ── Caller factories ─────────────────────────────────────────────────────────
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "mgr@example.com", name: "Manager", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_mgr",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
function createAdminCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "admin@example.com", name: "Admin", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_admin",
systemRole: SystemRole.ADMIN,
permissionOverrides: null,
},
});
}
// ── Sample data ──────────────────────────────────────────────────────────────
function sampleEntitlement(overrides: Record<string, unknown> = {}) {
return {
id: "ent_1",
resourceId: "res_1",
year: 2026,
entitledDays: 30,
carryoverDays: 2,
usedDays: 5,
pendingDays: 3,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// ─── getBalance ──────────────────────────────────────────────────────────────
describe("entitlement.getBalance", () => {
it("returns vacation balance for a resource and year", async () => {
const entitlement = sampleEntitlement();
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.year).toBe(2026);
expect(result.resourceId).toBe("res_1");
expect(result.entitledDays).toBe(30);
expect(result.remainingDays).toBe(22); // 30 - 5 - 3
expect(result).toHaveProperty("sickDays");
});
it("creates entitlement with carryover when none exists", async () => {
const prevEntitlement = sampleEntitlement({
id: "ent_prev",
year: 2025,
entitledDays: 28,
usedDays: 20,
pendingDays: 0,
});
const createdEntitlement = sampleEntitlement({
year: 2026,
entitledDays: 36, // 28 default + 8 carryover
carryoverDays: 8,
usedDays: 0,
pendingDays: 0,
});
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
vacationEntitlement: {
findUnique: vi
.fn()
.mockResolvedValueOnce(null) // current year not found
.mockResolvedValueOnce(prevEntitlement), // previous year found
create: vi.fn().mockResolvedValue(createdEntitlement),
update: vi.fn().mockResolvedValue(createdEntitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.entitledDays).toBe(36);
expect(result.carryoverDays).toBe(8);
expect(db.vacationEntitlement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resourceId: "res_1",
year: 2026,
carryoverDays: 8,
}),
}),
);
});
it("uses default of 28 days when no system settings exist", async () => {
const entitlement = sampleEntitlement({ entitledDays: 28, carryoverDays: 0 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.entitledDays).toBe(28);
});
it("counts sick days separately", async () => {
const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi
.fn()
// First call: balance-type vacations (for syncEntitlement)
.mockResolvedValueOnce([])
// Second call: sick days
.mockResolvedValueOnce([
{
startDate: new Date("2026-03-10"),
endDate: new Date("2026-03-12"),
isHalfDay: false,
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.sickDays).toBe(3);
});
});
// ─── get ─────────────────────────────────────────────────────────────────────
describe("entitlement.get", () => {
it("returns existing entitlement (manager role)", async () => {
const entitlement = sampleEntitlement();
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
},
};
const caller = createManagerCaller(db);
const result = await caller.get({ resourceId: "res_1", year: 2026 });
expect(result.id).toBe("ent_1");
expect(result.entitledDays).toBe(30);
});
it("rejects access by a regular user (FORBIDDEN)", async () => {
const db = {
systemSettings: {
findUnique: vi.fn(),
},
vacationEntitlement: {
findUnique: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(caller.get({ resourceId: "res_1", year: 2026 })).rejects.toThrow();
});
});
// ─── set ─────────────────────────────────────────────────────────────────────
describe("entitlement.set", () => {
it("updates existing entitlement", async () => {
const existing = sampleEntitlement();
const updated = { ...existing, entitledDays: 35 };
const db = {
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(existing),
update: vi.fn().mockResolvedValue(updated),
create: vi.fn(),
},
};
const caller = createManagerCaller(db);
const result = await caller.set({
resourceId: "res_1",
year: 2026,
entitledDays: 35,
});
expect(result.entitledDays).toBe(35);
expect(db.vacationEntitlement.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "ent_1" },
data: { entitledDays: 35 },
}),
);
expect(db.vacationEntitlement.create).not.toHaveBeenCalled();
});
it("creates new entitlement when none exists", async () => {
const created = sampleEntitlement({ entitledDays: 30, carryoverDays: 0 });
const db = {
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(null),
update: vi.fn(),
create: vi.fn().mockResolvedValue(created),
},
};
const caller = createManagerCaller(db);
const result = await caller.set({
resourceId: "res_1",
year: 2026,
entitledDays: 30,
});
expect(result.entitledDays).toBe(30);
expect(db.vacationEntitlement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resourceId: "res_1",
year: 2026,
entitledDays: 30,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
}),
}),
);
expect(db.vacationEntitlement.update).not.toHaveBeenCalled();
});
});
// ─── bulkSet ─────────────────────────────────────────────────────────────────
describe("entitlement.bulkSet", () => {
it("upserts entitlements for all active resources (admin role)", async () => {
const resources = [{ id: "res_1" }, { id: "res_2" }, { id: "res_3" }];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
vacationEntitlement: {
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
},
};
const caller = createAdminCaller(db);
const result = await caller.bulkSet({
year: 2026,
entitledDays: 30,
});
expect(result.updated).toBe(3);
expect(db.vacationEntitlement.upsert).toHaveBeenCalledTimes(3);
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ isActive: true }),
}),
);
});
it("filters by resourceIds when provided", async () => {
const resources = [{ id: "res_1" }];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
vacationEntitlement: {
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
},
};
const caller = createAdminCaller(db);
await caller.bulkSet({
year: 2026,
entitledDays: 30,
resourceIds: ["res_1"],
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
id: { in: ["res_1"] },
}),
}),
);
});
it("rejects bulk set by a manager (admin only)", async () => {
const db = {
resource: { findMany: vi.fn() },
vacationEntitlement: { upsert: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.bulkSet({ year: 2026, entitledDays: 30 }),
).rejects.toThrow();
});
});
// ─── getYearSummary ──────────────────────────────────────────────────────────
describe("entitlement.getYearSummary", () => {
it("returns summary for all active resources (manager role)", async () => {
const resources = [
{ id: "res_1", displayName: "Alice", eid: "alice", chapter: "VFX" },
{ id: "res_2", displayName: "Bob", eid: "bob", chapter: "Animation" },
];
const entitlement = sampleEntitlement({ usedDays: 5, pendingDays: 2 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createManagerCaller(db);
const result = await caller.getYearSummary({ year: 2026 });
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("resourceId");
expect(result[0]).toHaveProperty("displayName");
expect(result[0]).toHaveProperty("remainingDays");
});
it("filters by chapter when provided", async () => {
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
vacationEntitlement: {
findUnique: vi.fn(),
update: vi.fn(),
},
vacation: {
findMany: vi.fn(),
},
};
const caller = createManagerCaller(db);
await caller.getYearSummary({ year: 2026, chapter: "VFX" });
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
chapter: "VFX",
}),
}),
);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,629 @@
import { SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { experienceMultiplierRouter } from "../router/experience-multiplier.js";
import { createCallerFactory } from "../trpc.js";
// Mock the engine — we focus on the router/DB layer, not the pure engine logic
vi.mock("@planarchy/engine", () => ({
applyExperienceMultipliers: vi.fn().mockReturnValue({
adjustedCostRateCents: 12000,
adjustedBillRateCents: 18000,
adjustedHours: 110,
appliedRules: ["Rate multiplied (chapter=VFX): cost x1.2, bill x1.2"],
}),
applyExperienceMultipliersBatch: vi.fn().mockReturnValue({
results: [
{
adjustedCostRateCents: 12000,
adjustedBillRateCents: 18000,
adjustedHours: 110,
appliedRules: ["Rate multiplied (chapter=VFX): cost x1.2, bill x1.2"],
},
],
totalOriginalHours: 100,
totalAdjustedHours: 110,
linesAdjusted: 1,
}),
}));
const createCaller = createCallerFactory(experienceMultiplierRouter);
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "ctrl@example.com", name: "Controller", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_ctrl",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "mgr@example.com", name: "Manager", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_mgr",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
// ── Sample data factories ────────────────────────────────────────────────────
function sampleMultiplierSet(overrides: Record<string, unknown> = {}) {
return {
id: "ems_1",
name: "Standard Multipliers",
description: null,
isDefault: true,
createdAt: new Date(),
updatedAt: new Date(),
rules: [
{
id: "emr_1",
multiplierSetId: "ems_1",
chapter: "VFX",
location: null,
level: null,
costMultiplier: 1.2,
billMultiplier: 1.2,
shoringRatio: null,
additionalEffortRatio: null,
description: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
},
],
...overrides,
};
}
function sampleDemandLine(overrides: Record<string, unknown> = {}) {
return {
id: "dl_1",
name: "Compositing Senior",
chapter: "VFX",
costRateCents: 10000,
billRateCents: 15000,
hours: 100,
costTotalCents: 1000000,
priceTotalCents: 1500000,
metadata: null,
staffingAttributes: null,
createdAt: new Date(),
...overrides,
};
}
// ─── list ────────────────────────────────────────────────────────────────────
describe("experienceMultiplier.list", () => {
it("returns sets ordered by isDefault desc, name asc", async () => {
const sets = [
sampleMultiplierSet(),
sampleMultiplierSet({ id: "ems_2", name: "Custom", isDefault: false }),
];
const db = {
experienceMultiplierSet: {
findMany: vi.fn().mockResolvedValue(sets),
},
};
const caller = createControllerCaller(db);
const result = await caller.list();
expect(result).toHaveLength(2);
expect(db.experienceMultiplierSet.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
}),
);
});
});
// ─── getById ─────────────────────────────────────────────────────────────────
describe("experienceMultiplier.getById", () => {
it("returns the multiplier set when found", async () => {
const set = sampleMultiplierSet();
const db = {
experienceMultiplierSet: {
findUnique: vi.fn().mockResolvedValue(set),
},
};
const caller = createControllerCaller(db);
const result = await caller.getById({ id: "ems_1" });
expect(result.id).toBe("ems_1");
expect(result.rules).toHaveLength(1);
});
it("throws NOT_FOUND when set does not exist", async () => {
const db = {
experienceMultiplierSet: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createControllerCaller(db);
await expect(caller.getById({ id: "nonexistent" })).rejects.toThrow(
"Experience multiplier set not found",
);
});
});
// ─── create ──────────────────────────────────────────────────────────────────
describe("experienceMultiplier.create", () => {
it("creates a set with rules", async () => {
const created = sampleMultiplierSet();
const db = {
experienceMultiplierSet: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue(created),
},
};
const caller = createManagerCaller(db);
const result = await caller.create({
name: "Standard Multipliers",
isDefault: false,
rules: [
{
chapter: "VFX",
costMultiplier: 1.2,
billMultiplier: 1.2,
sortOrder: 0,
},
],
});
expect(result.id).toBe("ems_1");
expect(db.experienceMultiplierSet.create).toHaveBeenCalledTimes(1);
// isDefault was false, so updateMany should NOT have been called
expect(db.experienceMultiplierSet.updateMany).not.toHaveBeenCalled();
});
it("unsets other defaults when creating a new default set", async () => {
const created = sampleMultiplierSet({ isDefault: true });
const db = {
experienceMultiplierSet: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue(created),
},
};
const caller = createManagerCaller(db);
await caller.create({
name: "Standard Multipliers",
isDefault: true,
rules: [],
});
expect(db.experienceMultiplierSet.updateMany).toHaveBeenCalledWith({
where: { isDefault: true },
data: { isDefault: false },
});
});
});
// ─── update ──────────────────────────────────────────────────────────────────
describe("experienceMultiplier.update", () => {
it("updates name and description without touching rules", async () => {
const existing = sampleMultiplierSet();
const updated = { ...existing, name: "Updated Name" };
const db = {
experienceMultiplierSet: {
findUnique: vi.fn().mockResolvedValue(existing),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
update: vi.fn().mockResolvedValue(updated),
},
experienceMultiplierRule: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
};
const caller = createManagerCaller(db);
const result = await caller.update({ id: "ems_1", name: "Updated Name" });
expect(result.name).toBe("Updated Name");
// No rules provided, so rule replacement should not happen
expect(db.experienceMultiplierRule.deleteMany).not.toHaveBeenCalled();
expect(db.experienceMultiplierRule.createMany).not.toHaveBeenCalled();
});
it("replaces rules when rules array is provided", async () => {
const existing = sampleMultiplierSet();
const updated = sampleMultiplierSet({ name: "Updated" });
const db = {
experienceMultiplierSet: {
findUnique: vi.fn().mockResolvedValue(existing),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
update: vi.fn().mockResolvedValue(updated),
},
experienceMultiplierRule: {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
createMany: vi.fn().mockResolvedValue({ count: 2 }),
},
};
const caller = createManagerCaller(db);
await caller.update({
id: "ems_1",
rules: [
{ chapter: "VFX", costMultiplier: 1.3, billMultiplier: 1.3, sortOrder: 0 },
{ location: "India", costMultiplier: 0.7, billMultiplier: 0.9, shoringRatio: 0.5, sortOrder: 1 },
],
});
expect(db.experienceMultiplierRule.deleteMany).toHaveBeenCalledWith({
where: { multiplierSetId: "ems_1" },
});
expect(db.experienceMultiplierRule.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({ multiplierSetId: "ems_1", chapter: "VFX" }),
expect.objectContaining({ multiplierSetId: "ems_1", location: "India" }),
]),
});
});
it("throws NOT_FOUND when set does not exist", async () => {
const db = {
experienceMultiplierSet: {
findUnique: vi.fn().mockResolvedValue(null),
updateMany: vi.fn(),
update: vi.fn(),
},
experienceMultiplierRule: { deleteMany: vi.fn(), createMany: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(caller.update({ id: "nonexistent", name: "X" })).rejects.toThrow(
"Experience multiplier set not found",
);
});
});
// ─── delete ──────────────────────────────────────────────────────────────────
describe("experienceMultiplier.delete", () => {
it("deletes the set and returns its id", async () => {
const existing = sampleMultiplierSet();
const db = {
experienceMultiplierSet: {
findUnique: vi.fn().mockResolvedValue(existing),
delete: vi.fn().mockResolvedValue(existing),
},
};
const caller = createManagerCaller(db);
const result = await caller.delete({ id: "ems_1" });
expect(result).toEqual({ id: "ems_1" });
expect(db.experienceMultiplierSet.delete).toHaveBeenCalledWith({ where: { id: "ems_1" } });
});
it("throws NOT_FOUND when set does not exist", async () => {
const db = {
experienceMultiplierSet: {
findUnique: vi.fn().mockResolvedValue(null),
delete: vi.fn(),
},
};
const caller = createManagerCaller(db);
await expect(caller.delete({ id: "nonexistent" })).rejects.toThrow(
"Experience multiplier set not found",
);
});
});
// ─── preview ─────────────────────────────────────────────────────────────────
describe("experienceMultiplier.preview", () => {
function makeEstimate(demandLines: unknown[] = [sampleDemandLine()]) {
return {
id: "est_1",
versions: [
{
id: "v_1",
versionNumber: 1,
status: "WORKING",
demandLines,
},
],
};
}
it("returns preview results with summary stats", async () => {
const estimate = makeEstimate();
const multiplierSet = sampleMultiplierSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
};
const caller = createControllerCaller(db);
const result = await caller.preview({ estimateId: "est_1", multiplierSetId: "ems_1" });
expect(result.previews).toHaveLength(1);
expect(result.demandLineCount).toBe(1);
expect(result.multiplierSetName).toBe("Standard Multipliers");
expect(result.ruleCount).toBe(1);
// The mock returns adjusted values different from original, so hasChanges = true
expect(result.previews[0].hasChanges).toBe(true);
expect(result.previews[0].originalCostRateCents).toBe(10000);
expect(result.previews[0].adjustedCostRateCents).toBe(12000);
expect(result.linesChanged).toBe(1);
});
it("throws NOT_FOUND when estimate does not exist", async () => {
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(null) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(sampleMultiplierSet()) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "nope", multiplierSetId: "ems_1" })).rejects.toThrow(
"Estimate not found",
);
});
it("throws NOT_FOUND when multiplier set does not exist", async () => {
const estimate = makeEstimate();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(null) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "est_1", multiplierSetId: "nope" })).rejects.toThrow(
"Experience multiplier set not found",
);
});
it("throws NOT_FOUND when estimate has no versions", async () => {
const estimate = { id: "est_1", versions: [] };
const multiplierSet = sampleMultiplierSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "est_1", multiplierSetId: "ems_1" })).rejects.toThrow(
"Estimate has no versions",
);
});
it("reports no changes when rates are unchanged", async () => {
// Import the mock to override for this test
const { applyExperienceMultipliers } = await import("@planarchy/engine");
const mockFn = applyExperienceMultipliers as ReturnType<typeof vi.fn>;
mockFn.mockReturnValueOnce({
adjustedCostRateCents: 10000,
adjustedBillRateCents: 15000,
adjustedHours: 100,
appliedRules: ["No matching rule found -- values unchanged."],
});
const estimate = makeEstimate();
const multiplierSet = sampleMultiplierSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
};
const caller = createControllerCaller(db);
const result = await caller.preview({ estimateId: "est_1", multiplierSetId: "ems_1" });
expect(result.linesChanged).toBe(0);
expect(result.previews[0].hasChanges).toBe(false);
});
});
// ─── applyRules ──────────────────────────────────────────────────────────────
describe("experienceMultiplier.applyRules", () => {
function makeEstimate(versionStatus: string, demandLines: unknown[] = [sampleDemandLine()]) {
return {
id: "est_1",
versions: [
{
id: "v_1",
versionNumber: 1,
status: versionStatus,
demandLines,
},
],
};
}
it("updates demand lines with adjusted rates and creates audit log", async () => {
const estimate = makeEstimate("WORKING");
const multiplierSet = sampleMultiplierSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
estimateDemandLine: {
update: vi.fn().mockResolvedValue({}),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
const result = await caller.applyRules({
estimateId: "est_1",
multiplierSetId: "ems_1",
});
expect(result.linesUpdated).toBe(1);
expect(result.totalOriginalHours).toBe(100);
expect(result.totalAdjustedHours).toBe(110);
expect(db.estimateDemandLine.update).toHaveBeenCalledTimes(1);
// Verify the update call contains the adjusted rates and metadata
const updateCall = db.estimateDemandLine.update.mock.calls[0][0];
expect(updateCall.where).toEqual({ id: "dl_1" });
expect(updateCall.data.costRateCents).toBe(12000);
expect(updateCall.data.billRateCents).toBe(18000);
expect(updateCall.data.hours).toBe(110);
expect(updateCall.data.costTotalCents).toBe(Math.round(12000 * 110));
expect(updateCall.data.priceTotalCents).toBe(Math.round(18000 * 110));
expect(updateCall.data.metadata.experienceMultiplier).toEqual(
expect.objectContaining({
setId: "ems_1",
setName: "Standard Multipliers",
originalCostRateCents: 10000,
originalBillRateCents: 15000,
originalHours: 100,
}),
);
// Audit log should be created
expect(db.auditLog.create).toHaveBeenCalledTimes(1);
const auditCall = db.auditLog.create.mock.calls[0][0];
expect(auditCall.data.entityType).toBe("Estimate");
expect(auditCall.data.entityId).toBe("est_1");
expect(auditCall.data.action).toBe("UPDATE");
expect(auditCall.data.userId).toBe("user_mgr");
});
it("skips unchanged lines (no update call)", async () => {
const { applyExperienceMultipliersBatch } = await import("@planarchy/engine");
const mockFn = applyExperienceMultipliersBatch as ReturnType<typeof vi.fn>;
mockFn.mockReturnValueOnce({
results: [
{
adjustedCostRateCents: 10000,
adjustedBillRateCents: 15000,
adjustedHours: 100,
appliedRules: ["No matching rule found."],
},
],
totalOriginalHours: 100,
totalAdjustedHours: 100,
linesAdjusted: 0,
});
const estimate = makeEstimate("WORKING");
const multiplierSet = sampleMultiplierSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
estimateDemandLine: {
update: vi.fn(),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
const result = await caller.applyRules({
estimateId: "est_1",
multiplierSetId: "ems_1",
});
expect(result.linesUpdated).toBe(0);
expect(db.estimateDemandLine.update).not.toHaveBeenCalled();
});
it("rejects applying to a non-WORKING version", async () => {
const estimate = makeEstimate("SUBMITTED");
const multiplierSet = sampleMultiplierSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
estimateDemandLine: { update: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "est_1", multiplierSetId: "ems_1" }),
).rejects.toThrow("Can only apply multipliers to a WORKING version");
});
it("throws NOT_FOUND when estimate does not exist", async () => {
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(null) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(sampleMultiplierSet()) },
estimateDemandLine: { update: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "nope", multiplierSetId: "ems_1" }),
).rejects.toThrow("Estimate not found");
});
it("throws NOT_FOUND when multiplier set does not exist", async () => {
const estimate = makeEstimate("WORKING");
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(null) },
estimateDemandLine: { update: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "est_1", multiplierSetId: "nope" }),
).rejects.toThrow("Experience multiplier set not found");
});
it("throws NOT_FOUND when estimate has no versions", async () => {
const estimate = { id: "est_1", versions: [] };
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(sampleMultiplierSet()) },
estimateDemandLine: { update: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "est_1", multiplierSetId: "ems_1" }),
).rejects.toThrow("Estimate has no versions");
});
it("preserves existing metadata when updating demand lines", async () => {
const lineWithMetadata = sampleDemandLine({
metadata: { someField: "existing-value", anotherField: 42 },
});
const estimate = makeEstimate("WORKING", [lineWithMetadata]);
const multiplierSet = sampleMultiplierSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
estimateDemandLine: {
update: vi.fn().mockResolvedValue({}),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
await caller.applyRules({ estimateId: "est_1", multiplierSetId: "ems_1" });
const updateCall = db.estimateDemandLine.update.mock.calls[0][0];
// Existing metadata fields should be preserved alongside experienceMultiplier
expect(updateCall.data.metadata.someField).toBe("existing-value");
expect(updateCall.data.metadata.anotherField).toBe(42);
expect(updateCall.data.metadata.experienceMultiplier).toBeDefined();
});
});
@@ -0,0 +1,263 @@
import { SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { notificationRouter } from "../router/notification.js";
import { createCallerFactory } from "../trpc.js";
// Mock the SSE event bus — we don't test real event emission here
vi.mock("../sse/event-bus.js", () => ({
emitNotificationCreated: vi.fn(),
}));
const createCaller = createCallerFactory(notificationRouter);
// ── Caller factories ─────────────────────────────────────────────────────────
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "mgr@example.com", name: "Manager", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_mgr",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
// ── Sample data ──────────────────────────────────────────────────────────────
function sampleNotification(overrides: Record<string, unknown> = {}) {
return {
id: "notif_1",
userId: "user_1",
type: "VACATION_APPROVED",
title: "Vacation approved",
body: null,
entityId: null,
entityType: null,
readAt: null,
createdAt: new Date("2026-01-15T10:00:00Z"),
...overrides,
};
}
/** DB mock that resolves the session user for resolveUserId */
function withUserLookup(db: Record<string, unknown>, userId = "user_1") {
return {
user: {
findUnique: vi.fn().mockResolvedValue({ id: userId }),
},
...db,
};
}
// ─── list ────────────────────────────────────────────────────────────────────
describe("notification.list", () => {
it("returns notifications for the current user", async () => {
const notifications = [sampleNotification(), sampleNotification({ id: "notif_2" })];
const db = withUserLookup({
notification: {
findMany: vi.fn().mockResolvedValue(notifications),
},
});
const caller = createProtectedCaller(db);
const result = await caller.list({ limit: 50 });
expect(result).toHaveLength(2);
expect(db.notification.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ userId: "user_1" }),
orderBy: { createdAt: "desc" },
take: 50,
}),
);
});
it("filters to unread only when unreadOnly is true", async () => {
const db = withUserLookup({
notification: {
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createProtectedCaller(db);
await caller.list({ unreadOnly: true, limit: 10 });
expect(db.notification.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ userId: "user_1", readAt: null }),
take: 10,
}),
);
});
});
// ─── unreadCount ─────────────────────────────────────────────────────────────
describe("notification.unreadCount", () => {
it("returns count of unread notifications", async () => {
const db = withUserLookup({
notification: {
count: vi.fn().mockResolvedValue(5),
},
});
const caller = createProtectedCaller(db);
const result = await caller.unreadCount();
expect(result).toBe(5);
expect(db.notification.count).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user_1", readAt: null },
}),
);
});
});
// ─── markRead ────────────────────────────────────────────────────────────────
describe("notification.markRead", () => {
it("marks a single notification as read when id is provided", async () => {
const db = withUserLookup({
notification: {
update: vi.fn().mockResolvedValue(sampleNotification({ readAt: new Date() })),
updateMany: vi.fn(),
},
});
const caller = createProtectedCaller(db);
await caller.markRead({ id: "notif_1" });
expect(db.notification.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "notif_1", userId: "user_1" },
data: expect.objectContaining({ readAt: expect.any(Date) }),
}),
);
expect(db.notification.updateMany).not.toHaveBeenCalled();
});
it("marks all unread notifications as read when no id is provided", async () => {
const db = withUserLookup({
notification: {
update: vi.fn(),
updateMany: vi.fn().mockResolvedValue({ count: 3 }),
},
});
const caller = createProtectedCaller(db);
await caller.markRead({});
expect(db.notification.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user_1", readAt: null },
data: expect.objectContaining({ readAt: expect.any(Date) }),
}),
);
expect(db.notification.update).not.toHaveBeenCalled();
});
});
// ─── create ──────────────────────────────────────────────────────────────────
describe("notification.create", () => {
it("creates a notification (manager role)", async () => {
const created = sampleNotification({ userId: "target_user" });
const db = withUserLookup(
{
notification: {
create: vi.fn().mockResolvedValue(created),
},
},
"user_mgr",
);
const caller = createManagerCaller(db);
const result = await caller.create({
userId: "target_user",
type: "INFO",
title: "Test notification",
});
expect(result.id).toBe("notif_1");
expect(db.notification.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: "target_user",
type: "INFO",
title: "Test notification",
}),
}),
);
});
it("creates a notification with optional fields", async () => {
const created = sampleNotification({
userId: "target_user",
body: "Details here",
entityId: "proj_1",
entityType: "PROJECT",
});
const db = withUserLookup(
{
notification: {
create: vi.fn().mockResolvedValue(created),
},
},
"user_mgr",
);
const caller = createManagerCaller(db);
await caller.create({
userId: "target_user",
type: "INFO",
title: "Test",
body: "Details here",
entityId: "proj_1",
entityType: "PROJECT",
});
expect(db.notification.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
body: "Details here",
entityId: "proj_1",
entityType: "PROJECT",
}),
}),
);
});
it("rejects creation by a regular user (FORBIDDEN)", async () => {
const db = withUserLookup({
notification: {
create: vi.fn(),
},
});
const caller = createProtectedCaller(db);
await expect(
caller.create({ userId: "target", type: "INFO", title: "Nope" }),
).rejects.toThrow();
});
});
@@ -0,0 +1,523 @@
import { SystemRole } from "@planarchy/shared";
import { ResourceType } from "@planarchy/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@planarchy/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@planarchy/application")>();
return {
...actual,
isChargeabilityActualBooking: actual.isChargeabilityActualBooking,
isChargeabilityRelevantProject: actual.isChargeabilityRelevantProject,
listAssignmentBookings: vi.fn(),
recomputeResourceValueScores: vi.fn(),
};
});
import { listAssignmentBookings } from "@planarchy/application";
import { resourceRouter } from "../router/resource.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(resourceRouter);
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "controller@example.com", name: "Controller", image: null, role: "CONTROLLER" },
expires: "2026-03-14T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_controller",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null, role: "USER" },
expires: "2026-03-14T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
describe("resource router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("filters proposed utilization rows unless explicitly requested", async () => {
const resource = {
id: "resource_1",
eid: "E-001",
displayName: "Alice",
email: "alice@example.com",
chapter: "CGI",
lcrCents: 5000,
ucrCents: 9000,
currency: "EUR",
chargeabilityTarget: 80,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
skills: [],
dynamicFields: {},
blueprintId: null,
isActive: true,
createdAt: new Date("2026-03-01"),
updatedAt: new Date("2026-03-01"),
roleId: null,
portfolioUrl: null,
postalCode: null,
federalState: null,
valueScore: null,
valueScoreBreakdown: null,
valueScoreUpdatedAt: null,
userId: null,
};
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([resource]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_confirmed",
projectId: "project_1",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "CONFIRMED",
project: {
id: "project_1",
name: "Project 1",
shortCode: "P1",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
{
id: "assignment_proposed",
projectId: "project_2",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "PROPOSED",
project: {
id: "project_2",
name: "Project 2",
shortCode: "P2",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const strict = await caller.listWithUtilization({
startDate: "2026-03-02T00:00:00.000Z",
endDate: "2026-03-08T00:00:00.000Z",
});
const withProposed = await caller.listWithUtilization({
startDate: "2026-03-02T00:00:00.000Z",
endDate: "2026-03-08T00:00:00.000Z",
includeProposed: true,
});
expect(strict[0]).toMatchObject({
bookingCount: 1,
bookedHours: 20,
utilizationPercent: 50,
});
expect(withProposed[0]).toMatchObject({
bookingCount: 2,
bookedHours: 40,
utilizationPercent: 100,
});
});
it("uses a composite displayName/id cursor for stable pagination", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{ id: "2", displayName: "Alex", eid: "E-002", email: "alex2@example.com" },
{ id: "3", displayName: "Bea", eid: "E-003", email: "bea@example.com" },
]),
count: vi.fn().mockResolvedValue(3),
},
};
const caller = createProtectedCaller(db);
const result = await caller.list({
limit: 1,
cursor: JSON.stringify({ displayName: "Alex", id: "1" }),
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
AND: expect.arrayContaining([
{
OR: [
{ displayName: { gt: "Alex" } },
{ displayName: "Alex", id: { gt: "1" } },
],
},
]),
}),
orderBy: [{ displayName: "asc" }, { id: "asc" }],
take: 2,
}),
);
expect(result.nextCursor).toBe(JSON.stringify({ displayName: "Alex", id: "2" }));
});
it("resolves resource ownership server-side without exposing linked user email", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
eid: "E-001",
displayName: "Alice",
email: "alice@example.com",
chapter: "CGI",
lcrCents: 5000,
ucrCents: 9000,
currency: "EUR",
chargeabilityTarget: 80,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
skills: [],
dynamicFields: {},
blueprint: null,
blueprintId: null,
isActive: true,
createdAt: new Date("2026-03-01"),
updatedAt: new Date("2026-03-01"),
resourceRoles: [],
areaRole: null,
portfolioUrl: null,
roleId: null,
aiSummary: null,
aiSummaryUpdatedAt: null,
skillMatrixUpdatedAt: null,
valueScore: null,
valueScoreBreakdown: null,
valueScoreUpdatedAt: null,
userId: "user_1",
}),
findMany: vi.fn().mockResolvedValue([]),
},
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getById({ id: "resource_1" });
expect(result).toMatchObject({
id: "resource_1",
displayName: "Alice",
eid: "E-001",
email: "alice@example.com",
isOwnedByCurrentUser: true,
});
expect(result).not.toHaveProperty("user");
expect(db.resource.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.not.objectContaining({
user: expect.anything(),
}),
}),
);
});
it("counts imported TBD draft projects in chargeability stats only when proposed work is enabled", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_1",
eid: "E-001",
displayName: "Alice",
chapter: "CGI",
chargeabilityTarget: 80,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
},
]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_tbd",
projectId: "project_tbd",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "PROPOSED",
project: {
id: "project_tbd",
name: "TBD Project",
shortCode: "TBD-P1",
status: "DRAFT",
orderType: "CLIENT",
dynamicFields: { dispoImport: { isTbd: true } },
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const strict = await caller.getChargeabilityStats({});
const withProposed = await caller.getChargeabilityStats({ includeProposed: true });
expect(strict[0]?.actualChargeability).toBe(0);
expect(strict[0]?.expectedChargeability).toBeGreaterThan(0);
expect(withProposed[0]?.actualChargeability).toBeGreaterThan(strict[0]?.actualChargeability ?? 0);
expect(withProposed[0]?.expectedChargeability).toBe(strict[0]?.expectedChargeability);
});
it("applies country filters including explicit no-country toggle", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
};
const caller = createProtectedCaller(db);
await caller.list({
countryIds: ["country_de", "country_us"],
includeWithoutCountry: false,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: expect.arrayContaining([
{ isActive: true },
{ countryId: { in: ["country_de", "country_us"] } },
]),
},
}),
);
});
it("excludes disabled countries while leaving all others visible", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
};
const caller = createProtectedCaller(db);
await caller.list({
excludedCountryIds: ["country_fr"],
includeWithoutCountry: true,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: expect.arrayContaining([
{ isActive: true },
{ NOT: { countryId: { in: ["country_fr"] } } },
]),
},
}),
);
});
it("applies resource type filters while keeping unspecified rows when requested", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
};
const caller = createProtectedCaller(db);
await caller.list({
resourceTypes: [ResourceType.EMPLOYEE, ResourceType.INTERN],
includeWithoutResourceType: true,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: expect.arrayContaining([
{ isActive: true },
{
OR: [
{ resourceType: { in: [ResourceType.EMPLOYEE, ResourceType.INTERN] } },
{ resourceType: null },
],
},
]),
},
}),
);
});
it("excludes disabled resource types while leaving all others visible", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
};
const caller = createProtectedCaller(db);
await caller.list({
excludedResourceTypes: [ResourceType.FREELANCER],
includeWithoutResourceType: true,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: expect.arrayContaining([
{ isActive: true },
{ NOT: { resourceType: { in: [ResourceType.FREELANCER] } } },
]),
},
}),
);
});
it("applies rolled-off and departed filters", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
};
const caller = createProtectedCaller(db);
await caller.list({
rolledOff: true,
departed: false,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: expect.arrayContaining([
{ isActive: true },
{ rolledOff: true },
{ departed: false },
]),
},
}),
);
});
it("applies multi-select chapter filters", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
};
const caller = createProtectedCaller(db);
await caller.list({
chapters: ["Art Direction", "Project Management"],
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: expect.arrayContaining([
{ isActive: true },
{ chapter: { in: ["Art Direction", "Project Management"] } },
]),
},
}),
);
});
it("supports stable anonymized identities and alias-based filtering", async () => {
const resource = {
id: "resource_anon_1",
eid: "h.noerenberg",
displayName: "Hartmut Noerenberg",
email: "h.noerenberg@accenture.com",
chapter: "Art Direction",
lcrCents: 15000,
isActive: true,
createdAt: new Date("2026-03-01"),
updatedAt: new Date("2026-03-01"),
};
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({
anonymizationEnabled: true,
anonymizationDomain: "superhartmut.de",
anonymizationSeed: null,
anonymizationMode: "global",
}),
},
resource: {
findMany: vi.fn().mockResolvedValue([resource]),
count: vi.fn(),
},
};
const caller = createProtectedCaller(db);
const first = await caller.list({ limit: 10 });
const alias = first.resources[0];
expect(alias).toBeDefined();
expect(alias?.displayName).not.toBe(resource.displayName);
expect(alias?.eid).not.toBe(resource.eid);
expect(alias?.email).toBe(`${alias?.eid}@superhartmut.de`);
const byAlias = await caller.list({ eids: [alias!.eid], limit: 10 });
const byAliasSearch = await caller.list({ search: alias!.displayName.slice(0, 4), limit: 10 });
expect(byAlias.resources).toHaveLength(1);
expect(byAlias.resources[0]?.id).toBe(resource.id);
expect(byAliasSearch.resources).toHaveLength(1);
expect(byAliasSearch.resources[0]?.id).toBe(resource.id);
});
});
@@ -0,0 +1,297 @@
import { SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { staffingRouter } from "../router/staffing.js";
import { createCallerFactory } from "../trpc.js";
// Mock the pure-logic packages — we focus on the router/DB layer
vi.mock("@planarchy/staffing", () => ({
rankResources: vi.fn().mockImplementation((input: { resources: { id: string }[] }) =>
input.resources.map((r: { id: string }, i: number) => ({
resourceId: r.id,
score: 80 - i * 10,
breakdown: {
skillScore: 70,
availabilityScore: 90,
costScore: 80,
utilizationScore: 75,
},
})),
),
analyzeUtilization: vi.fn().mockReturnValue({
resourceId: "res_1",
displayName: "Alice",
totalDays: 20,
allocatedDays: 15,
utilizationPercent: 75,
chargeablePercent: 60,
overallocatedDays: 0,
dailyBreakdown: [],
}),
findCapacityWindows: vi.fn().mockReturnValue([
{
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-10"),
availableHoursPerDay: 6,
},
]),
}));
vi.mock("@planarchy/application", () => ({
listAssignmentBookings: vi.fn().mockResolvedValue([]),
}));
const createCaller = createCallerFactory(staffingRouter);
// ── Caller factories ─────────────────────────────────────────────────────────
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
// ── Sample data ──────────────────────────────────────────────────────────────
function sampleResource(overrides: Record<string, unknown> = {}) {
return {
id: "res_1",
displayName: "Alice",
eid: "alice",
lcrCents: 7500,
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
skills: [
{ skill: "Compositing", proficiency: 4, isMainSkill: true },
{ skill: "Nuke", proficiency: 3, isMainSkill: false, category: "Software" },
],
isActive: true,
valueScore: 85,
chapter: "VFX",
...overrides,
};
}
// ─── getSuggestions ──────────────────────────────────────────────────────────
describe("staffing.getSuggestions", () => {
it("returns ranked suggestions for a staffing demand", async () => {
const resources = [
sampleResource(),
sampleResource({ id: "res_2", displayName: "Bob", eid: "bob", valueScore: 70 }),
];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
hoursPerDay: 8,
});
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("resourceId");
expect(result[0]).toHaveProperty("score");
});
it("filters resources by chapter when provided", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
hoursPerDay: 8,
chapter: "VFX",
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
chapter: "VFX",
}),
}),
);
});
it("returns empty array when no resources match", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
hoursPerDay: 8,
});
expect(result).toHaveLength(0);
});
it("passes budget constraint to ranking", async () => {
const resources = [sampleResource()];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
};
const { rankResources } = await import("@planarchy/staffing");
const caller = createProtectedCaller(db);
await caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
hoursPerDay: 8,
budgetLcrCentsPerHour: 10000,
});
expect(rankResources).toHaveBeenCalledWith(
expect.objectContaining({
budgetLcrCentsPerHour: 10000,
}),
);
});
});
// ─── analyzeUtilization ──────────────────────────────────────────────────────
describe("staffing.analyzeUtilization", () => {
it("returns utilization analysis for an existing resource", async () => {
const resource = {
id: "res_1",
displayName: "Alice",
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
};
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(resource),
},
};
const caller = createProtectedCaller(db);
const result = await caller.analyzeUtilization({
resourceId: "res_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
});
expect(result).toHaveProperty("utilizationPercent");
expect(result.resourceId).toBe("res_1");
});
it("throws NOT_FOUND when resource does not exist", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.analyzeUtilization({
resourceId: "nonexistent",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
}),
).rejects.toThrow("Resource not found");
});
});
// ─── findCapacity ────────────────────────────────────────────────────────────
describe("staffing.findCapacity", () => {
it("returns capacity windows for an existing resource", async () => {
const resource = {
id: "res_1",
displayName: "Alice",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
};
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(resource),
},
};
const caller = createProtectedCaller(db);
const result = await caller.findCapacity({
resourceId: "res_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
});
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("availableHoursPerDay");
});
it("throws NOT_FOUND when resource does not exist", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.findCapacity({
resourceId: "nonexistent",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
}),
).rejects.toThrow("Resource not found");
});
it("passes minAvailableHoursPerDay to engine", async () => {
const resource = {
id: "res_1",
displayName: "Alice",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
};
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(resource),
},
};
const { findCapacityWindows } = await import("@planarchy/staffing");
const caller = createProtectedCaller(db);
await caller.findCapacity({
resourceId: "res_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
minAvailableHoursPerDay: 6,
});
expect(findCapacityWindows).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.any(Date),
expect.any(Date),
6,
);
});
});
@@ -0,0 +1,854 @@
import { SystemRole } from "@planarchy/shared";
import { VacationStatus, VacationType } from "@planarchy/db";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { vacationRouter } from "../router/vacation.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../sse/event-bus.js", () => ({
emitVacationCreated: vi.fn(),
emitVacationUpdated: vi.fn(),
emitVacationDeleted: vi.fn(),
emitNotificationCreated: vi.fn(),
}));
vi.mock("../lib/email.js", () => ({
sendEmail: vi.fn(),
}));
const createCaller = createCallerFactory(vacationRouter);
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2026-12-31T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "manager@example.com", name: "Manager", image: null },
expires: "2026-12-31T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "mgr_1",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
function createAdminCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "admin@example.com", name: "Admin", image: null },
expires: "2026-12-31T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "admin_1",
systemRole: SystemRole.ADMIN,
permissionOverrides: null,
},
});
}
function createUnauthenticatedCaller(db: Record<string, unknown>) {
return createCaller({
session: null,
db: db as never,
dbUser: null,
});
}
const sampleVacation = {
id: "vac_1",
resourceId: "res_1",
type: VacationType.ANNUAL,
status: VacationStatus.PENDING,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
note: "Summer vacation",
isHalfDay: false,
halfDayPart: null,
requestedById: "user_1",
approvedById: null,
approvedAt: null,
rejectionReason: null,
createdAt: new Date("2026-03-01"),
updatedAt: new Date("2026-03-01"),
resource: { id: "res_1", displayName: "Alice", eid: "E-001" },
requestedBy: { id: "user_1", name: "User", email: "user@example.com" },
approvedBy: null,
};
describe("vacation router", () => {
describe("list", () => {
it("returns vacations with default filters", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([sampleVacation]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.list({});
expect(result).toHaveLength(1);
expect(result[0].id).toBe("vac_1");
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: { startDate: "asc" },
take: 100,
}),
);
});
it("applies resourceId filter", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.list({ resourceId: "res_1" });
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ resourceId: "res_1" }),
}),
);
});
it("applies status and type filters", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.list({
status: VacationStatus.APPROVED,
type: VacationType.SICK,
});
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: VacationStatus.APPROVED,
type: VacationType.SICK,
}),
}),
);
});
it("rejects unauthenticated users", async () => {
const db = { vacation: { findMany: vi.fn() } };
const caller = createUnauthenticatedCaller(db);
await expect(caller.list({})).rejects.toThrow("Authentication required");
});
});
describe("getById", () => {
it("returns vacation by id", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getById({ id: "vac_1" });
expect(result.id).toBe("vac_1");
expect(db.vacation.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "vac_1" },
}),
);
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(caller.getById({ id: "missing" })).rejects.toThrow("Vacation not found");
});
});
describe("create", () => {
it("creates vacation as PENDING for regular user", async () => {
const createdVacation = {
...sampleVacation,
status: VacationStatus.PENDING,
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
const caller = createProtectedCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result.status).toBe(VacationStatus.PENDING);
expect(db.vacation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.PENDING,
resourceId: "res_1",
type: VacationType.ANNUAL,
}),
}),
);
});
it("creates vacation as APPROVED for manager", async () => {
const createdVacation = {
...sampleVacation,
status: VacationStatus.APPROVED,
approvedById: "mgr_1",
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
const caller = createManagerCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result.status).toBe(VacationStatus.APPROVED);
expect(db.vacation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.APPROVED,
approvedById: "mgr_1",
}),
}),
);
});
it("rejects overlapping vacation", async () => {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
}),
).rejects.toThrow("Overlapping vacation already exists");
});
it("rejects when end date is before start date", async () => {
const db = {
user: { findUnique: vi.fn() },
vacation: { findFirst: vi.fn() },
};
const caller = createProtectedCaller(db);
await expect(
caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-05"),
endDate: new Date("2026-06-01"),
}),
).rejects.toThrow();
});
it("supports half-day vacations", async () => {
const createdVacation = {
...sampleVacation,
isHalfDay: true,
halfDayPart: "MORNING",
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
const caller = createProtectedCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-01"),
isHalfDay: true,
halfDayPart: "MORNING",
});
expect(result.isHalfDay).toBe(true);
expect(db.vacation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
isHalfDay: true,
halfDayPart: "MORNING",
}),
}),
);
});
});
describe("approve", () => {
it("approves a PENDING vacation", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.APPROVED,
approvedById: "mgr_1",
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.approve({ id: "vac_1" });
expect(result.status).toBe(VacationStatus.APPROVED);
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "vac_1" },
data: expect.objectContaining({
status: VacationStatus.APPROVED,
rejectionReason: null,
}),
}),
);
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "missing" })).rejects.toThrow("Vacation not found");
});
it("rejects approving an already APPROVED vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
};
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
"Only PENDING, CANCELLED, or REJECTED vacations can be approved",
);
});
it("forbids regular users from approving", async () => {
const db = {};
const caller = createProtectedCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow();
});
});
describe("reject", () => {
it("rejects a PENDING vacation", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.REJECTED,
rejectionReason: "Team conflict",
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" });
expect(result.status).toBe(VacationStatus.REJECTED);
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.REJECTED,
rejectionReason: "Team conflict",
}),
}),
);
});
it("throws when rejecting non-PENDING vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
};
const caller = createManagerCaller(db);
await expect(caller.reject({ id: "vac_1" })).rejects.toThrow(
"Only PENDING vacations can be rejected",
);
});
});
describe("cancel", () => {
it("cancels an existing vacation", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.CANCELLED,
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
};
const caller = createProtectedCaller(db);
const result = await caller.cancel({ id: "vac_1" });
expect(result.status).toBe(VacationStatus.CANCELLED);
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "missing" })).rejects.toThrow("Vacation not found");
});
it("throws when already cancelled", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.CANCELLED,
}),
},
};
const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "vac_1" })).rejects.toThrow("Already cancelled");
});
});
describe("batchApprove", () => {
it("approves multiple pending vacations", async () => {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
{ id: "vac_2", resourceId: "res_2" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 2 }),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
expect(result.approved).toBe(2);
expect(db.vacation.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: ["vac_1", "vac_2"] } },
data: expect.objectContaining({
status: VacationStatus.APPROVED,
}),
}),
);
});
it("only approves PENDING vacations from the requested set", async () => {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_already_approved"] });
expect(result.approved).toBe(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: ["vac_1", "vac_already_approved"] }, status: VacationStatus.PENDING },
}),
);
});
});
describe("batchReject", () => {
it("rejects multiple pending vacations with optional reason", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.batchReject({
ids: ["vac_1"],
rejectionReason: "Budget freeze",
});
expect(result.rejected).toBe(1);
expect(db.vacation.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.REJECTED,
rejectionReason: "Budget freeze",
}),
}),
);
});
});
describe("getForResource", () => {
it("returns approved vacations in date range", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([
{
id: "vac_1",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
type: VacationType.ANNUAL,
status: VacationStatus.APPROVED,
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getForResource({
resourceId: "res_1",
startDate: new Date("2026-01-01"),
endDate: new Date("2026-12-31"),
});
expect(result).toHaveLength(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
resourceId: "res_1",
status: VacationStatus.APPROVED,
}),
}),
);
});
});
describe("getPendingApprovals", () => {
it("returns all pending vacations for managers", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([sampleVacation]),
},
};
const caller = createManagerCaller(db);
const result = await caller.getPendingApprovals();
expect(result).toHaveLength(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: VacationStatus.PENDING },
}),
);
});
it("forbids regular users from viewing pending approvals", async () => {
const db = {};
const caller = createProtectedCaller(db);
await expect(caller.getPendingApprovals()).rejects.toThrow();
});
});
describe("getTeamOverlap", () => {
it("returns overlapping vacations for the same chapter", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({ chapter: "Animation" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
...sampleVacation,
id: "vac_other",
resourceId: "res_2",
resource: { id: "res_2", displayName: "Bob", eid: "E-002" },
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getTeamOverlap({
resourceId: "res_1",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result).toHaveLength(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
resource: { chapter: "Animation" },
resourceId: { not: "res_1" },
}),
}),
);
});
it("returns empty array when resource has no chapter", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({ chapter: null }),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getTeamOverlap({
resourceId: "res_1",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result).toEqual([]);
});
});
describe("batchCreatePublicHolidays", () => {
it("creates public holidays for all active resources (admin only)", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{ id: "res_1" },
{ id: "res_2" },
]),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
},
vacation: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({}),
},
};
const caller = createAdminCaller(db);
const result = await caller.batchCreatePublicHolidays({
year: 2026,
federalState: "BY",
});
expect(result.created).toBeGreaterThan(0);
expect(result.resources).toBe(2);
expect(db.vacation.create).toHaveBeenCalled();
});
it("skips already existing holidays", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([{ id: "res_1" }]),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue({ id: "existing" }),
create: vi.fn(),
},
};
const caller = createAdminCaller(db);
const result = await caller.batchCreatePublicHolidays({
year: 2026,
federalState: "BY",
});
expect(result.created).toBe(0);
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("forbids non-admin users", async () => {
const db = {};
const caller = createManagerCaller(db);
await expect(
caller.batchCreatePublicHolidays({ year: 2026 }),
).rejects.toThrow("Admin role required");
});
});
describe("updateStatus", () => {
it("allows manager to approve via updateStatus", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.APPROVED,
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
};
const caller = createManagerCaller(db);
const result = await caller.updateStatus({ id: "vac_1", status: "APPROVED" });
expect(result.status).toBe(VacationStatus.APPROVED);
});
it("allows any user to cancel via updateStatus", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.CANCELLED,
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
};
const caller = createProtectedCaller(db);
const result = await caller.updateStatus({ id: "vac_1", status: "CANCELLED" });
expect(result.status).toBe(VacationStatus.CANCELLED);
});
it("forbids non-manager from approving via updateStatus", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.updateStatus({ id: "vac_1", status: "APPROVED" }),
).rejects.toThrow("Manager role required to approve/reject");
});
it("throws NOT_FOUND when vacation does not exist", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.updateStatus({ id: "missing", status: "CANCELLED" }),
).rejects.toThrow("Vacation not found");
});
});
});
+841
View File
@@ -0,0 +1,841 @@
import { createHash } from "node:crypto";
import type { PrismaClient } from "@planarchy/db";
const DEFAULT_ANONYMIZATION_DOMAIN = "superhartmut.de";
const DEFAULT_ANONYMIZATION_SEED = "planarchy-superhartmut-global";
type AliasEntry = {
name: string;
slug: string;
};
type ResourceIdentity = {
id: string;
eid?: string | null;
displayName?: string | null;
email?: string | null;
};
type UserIdentity = {
id?: string | null;
name?: string | null;
email?: string | null;
};
type DirectoryResource = {
id: string;
eid: string;
displayName: string;
email: string;
lcrCents: number;
};
type DirectoryIdentity = Pick<DirectoryResource, "id" | "eid" | "displayName" | "email">;
export type AnonymizationConfig = {
enabled: boolean;
domain: string;
seed: string;
mode: "global";
};
export type ResourceAlias = {
displayName: string;
eid: string;
email: string;
};
type AnonymizationDirectory = {
config: AnonymizationConfig;
byResourceId: Map<string, ResourceAlias>;
byAliasEid: Map<string, string>;
};
type StoredAliasEntry = {
displayName: string;
eid: string;
};
type StoredAliasMap = Record<string, StoredAliasEntry>;
const ALIAS_NAME_RE = /^[A-Za-z]+(?: [A-Za-z]+)*$/;
const ALIAS_SLUG_RE = /^[a-z]+(?:\.[a-z]+)*$/;
const ICONIC_ALIAS_NAMES = [
"Iron Man",
"Spider Man",
"Captain America",
"Thor",
"Hulk",
"Black Widow",
"Black Panther",
"Doctor Strange",
"Scarlet Witch",
"Captain Marvel",
"Wolverine",
"Batman",
"Superman",
"Wonder Woman",
"Flash",
"Aquaman",
"Deadpool",
"Loki",
"Thanos",
"Joker",
"Darth Vader",
"Harley Quinn",
"Doctor Doom",
"Magneto",
"Venom",
"Elsa",
"Mickey Mouse",
"Moana",
"Mulan",
"Simba",
"Stitch",
"Jack Sparrow",
"Maleficent",
"Cruella",
"Hades",
"Ursula",
"Luke Skywalker",
"Leia Organa",
"Han Solo",
"Obi Wan Kenobi",
"Yoda",
"Darth Maul",
"Buzz Lightyear",
"Woody",
"Belle",
"Green Goblin",
"Mystique",
"Poison Ivy",
"Ultron",
"Robert Downey",
"Scarlett Johansson",
"Chris Hemsworth",
"Ryan Reynolds",
"Hugh Jackman",
"Harrison Ford",
];
const LEGENDARY_ALIAS_NAMES = [
"Hawkeye",
"Falcon",
"Winter Soldier",
"Vision",
"Ant Man",
"Wasp",
"War Machine",
"Shuri",
"Shang Chi",
"Gamora",
"Star Lord",
"Rocket",
"Groot",
"Nebula",
"Daredevil",
"Punisher",
"Moon Knight",
"She Hulk",
"Ms Marvel",
"Kate Bishop",
"Yelena Belova",
"Pepper Potts",
"Nick Fury",
"Phil Coulson",
"Korg",
"Mantis",
"Drax",
"Sif",
"Professor X",
"Jean Grey",
"Cyclops",
"Rogue",
"Gambit",
"Silver Surfer",
"Ghost Rider",
"Blade",
"Killmonger",
"Taskmaster",
"Red Skull",
"Kingpin",
"Carnage",
"Sandman",
"Mysterio",
"Sabretooth",
"Abomination",
"Ronan",
"Scar",
"Jafar",
"Ursula",
"Gaston",
"Captain Hook",
"Cruella",
"Tiana",
"Rapunzel",
"Merida",
"Aladdin",
"Jasmine",
"Nala",
"Olaf",
"Kristoff",
"Maui",
"Baymax",
"Hercules",
"Megara",
"Tinker Bell",
"Baloo",
"Timon",
"Pumbaa",
"Ariel",
"Aurora",
"Pocahontas",
"Rey Skywalker",
"Kylo Ren",
"Ahsoka Tano",
"Chewbacca",
"Lando Calrissian",
"Mace Windu",
"Anakin Skywalker",
"Padme Amidala",
"Chris Evans",
"Tom Holland",
"Zendaya",
"Benedict Cumberbatch",
"Tom Hiddleston",
"Emma Stone",
"Margot Robbie",
"Christian Bale",
"Gal Gadot",
"Jason Momoa",
"Pedro Pascal",
"Keanu Reeves",
"Ryan Reynolds",
"Samuel Jackson",
"Brie Larson",
"Paul Rudd",
];
const EXTENDED_ALIAS_NAMES = [
"Okoye",
"Mbaku",
"Wong",
"Monica Rambeau",
"America Chavez",
"Kate Pryde",
"Nightcrawler",
"Beast",
"Elektra",
"Jessica Jones",
"Jessica Drew",
"Colossus",
"Domino",
"Cable",
"Nova",
"Adam Warlock",
"Clea",
"Ancient One",
"General Zod",
"Lex Luthor",
"Darkseid",
"Bane",
"Riddler",
"Two Face",
"Penguin",
"Catwoman",
"Mister Freeze",
"Red Hood",
"Nightwing",
"Robin",
"Supergirl",
"Batgirl",
"Green Lantern",
"Black Adam",
"Shazam",
"Raven",
"Starfire",
"Beast Boy",
"Doctor Octopus",
"Sandman",
"Mysterio",
"Sabretooth",
"Abomination",
"Ronan",
"Doctor Facilier",
"Mother Gothel",
"Prince Eric",
"Snow White",
"Cinderella",
"Pinocchio",
"Peter Pan",
"Winnie Pooh",
"Minnie Mouse",
"Donald Duck",
"Daisy Duck",
"Goofy",
"Pluto",
"Milo Thatch",
"Kida Nedakh",
"Li Shang",
"Genie",
"Flynn Rider",
"Prince Naveen",
"Qui Gon",
"Finn",
"Poe Dameron",
"Elizabeth Olsen",
"Mark Ruffalo",
"Natalie Portman",
"Emma Watson",
"Robert Pattinson",
"Angelina Jolie",
"Johnny Depp",
"Jeremy Renner",
"Tom Cruise",
"Will Smith",
"Dwayne Johnson",
"Jennifer Lawrence",
"Anne Hathaway",
"Meryl Streep",
"Charlize Theron",
"Brad Pitt",
];
const COMPOSITE_GIVEN_NAMES = [
"Tony",
"Peter",
"Steve",
"Natasha",
"Carol",
"Wanda",
"Logan",
"Bruce",
"Diana",
"Clark",
"Barry",
"Arthur",
"Selina",
"Harley",
"Loki",
"Thor",
"Shuri",
"Stephen",
"Leia",
"Luke",
"Anakin",
"Padme",
"Elsa",
"Moana",
"Mulan",
"Belle",
"Ariel",
"Simba",
"Tiana",
"Rapunzel",
"Mickey",
"Wade",
"Victor",
"Rey",
"Robert",
"Scarlett",
"Chris",
"Tom",
"Zendaya",
"Ryan",
"Keanu",
"Hugh",
"Emma",
"Pedro",
"Angelina",
"Brie",
"Paul",
"Jeremy",
"Samuel",
"Benedict",
"Gal",
"Jason",
"Margot",
"Christian",
"Harrison",
"Natalie",
"Mark",
"Elizabeth",
"Anne",
"Jennifer",
"Charlize",
];
const COMPOSITE_SURNAMES = [
"Stark",
"Parker",
"Rogers",
"Romanoff",
"Danvers",
"Maximoff",
"Howlett",
"Banner",
"Wayne",
"Kent",
"Allen",
"Curry",
"Prince",
"Quinn",
"Wilson",
"Odinson",
"Strange",
"Mouse",
"Lightyear",
"Skywalker",
"Solo",
"Kenobi",
"Vader",
"Ren",
"Palmer",
"Rambeau",
"Dameron",
"Tano",
"Downey",
"Evans",
"Johansson",
"Hemsworth",
"Holland",
"Reynolds",
"Jackman",
"Reeves",
"Stone",
"Watson",
"Pascal",
"Jolie",
"Larson",
"Rudd",
"Renner",
"Jackson",
"Cumberbatch",
"Hiddleston",
"Gadot",
"Momoa",
"Robbie",
"Bale",
"Ford",
"Portman",
"Ruffalo",
"Olsen",
"Lawrence",
"Hathaway",
"Theron",
"Pitt",
];
function normalizeAliasName(name: string): string {
return name.replace(/[^A-Za-z\s]+/g, " ").replace(/\s+/g, " ").trim();
}
function slugifyAliasName(name: string): string {
return normalizeAliasName(name).toLowerCase().split(" ").filter(Boolean).join(".");
}
function buildAliasEntries(names: string[]): AliasEntry[] {
const seen = new Set<string>();
const entries: AliasEntry[] = [];
for (const rawName of names) {
const name = normalizeAliasName(rawName);
const slug = slugifyAliasName(name);
if (!name || !slug || seen.has(slug)) {
continue;
}
if (!ALIAS_NAME_RE.test(name) || !ALIAS_SLUG_RE.test(slug)) {
continue;
}
seen.add(slug);
entries.push({ name, slug });
}
return entries;
}
function buildCompositeAliasEntries(givenNames: string[], surnames: string[]): AliasEntry[] {
const names: string[] = [];
for (const givenName of givenNames) {
for (const surname of surnames) {
if (givenName === surname) {
continue;
}
names.push(`${givenName} ${surname}`);
}
}
return buildAliasEntries(names);
}
const ICONIC_CHARACTERS = buildAliasEntries(ICONIC_ALIAS_NAMES);
const WELL_KNOWN_CHARACTERS = buildAliasEntries(LEGENDARY_ALIAS_NAMES);
const COMPOSITE_CHARACTERS = buildCompositeAliasEntries(COMPOSITE_GIVEN_NAMES, COMPOSITE_SURNAMES);
const SUPPORTING_CHARACTERS = buildAliasEntries([
...EXTENDED_ALIAS_NAMES,
...COMPOSITE_CHARACTERS.map((entry) => entry.name),
]);
const USER_CHARACTER_POOL = buildAliasEntries([
...ICONIC_ALIAS_NAMES,
...LEGENDARY_ALIAS_NAMES,
...EXTENDED_ALIAS_NAMES,
]);
function hashInt(input: string): number {
const hex = createHash("sha256").update(input).digest("hex").slice(0, 8);
return Number.parseInt(hex, 16);
}
function normalize(value: string | null | undefined): string {
return (value ?? "").trim().toLowerCase();
}
function isStoredAliasEntryValid(alias: StoredAliasEntry): boolean {
const displayName = normalizeAliasName(alias.displayName);
const eid = slugifyAliasName(alias.displayName);
return (
alias.displayName === displayName &&
alias.eid === eid &&
ALIAS_NAME_RE.test(displayName) &&
ALIAS_SLUG_RE.test(alias.eid) &&
!/\d/.test(alias.displayName) &&
!/\d/.test(alias.eid)
);
}
function parseStoredAliases(value: unknown): StoredAliasMap {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const entries = Object.entries(value);
const parsed: StoredAliasMap = {};
for (const [resourceId, alias] of entries) {
if (!alias || typeof alias !== "object" || Array.isArray(alias)) {
continue;
}
const displayName =
typeof (alias as { displayName?: unknown }).displayName === "string"
? (alias as { displayName: string }).displayName.trim()
: "";
const eid =
typeof (alias as { eid?: unknown }).eid === "string"
? (alias as { eid: string }).eid.trim()
: "";
if (!displayName || !eid) {
continue;
}
parsed[resourceId] = { displayName, eid };
}
return parsed;
}
function toAlias(entry: StoredAliasEntry, domain: string): ResourceAlias {
return {
displayName: entry.displayName,
eid: entry.eid,
email: `${entry.eid}@${domain}`,
};
}
function getCharacterPool(lcrCents: number): AliasEntry[] {
if (lcrCents >= 12000) return ICONIC_CHARACTERS;
if (lcrCents >= 9000) return WELL_KNOWN_CHARACTERS;
return SUPPORTING_CHARACTERS;
}
function pickUniqueAlias(
resource: DirectoryResource,
config: AnonymizationConfig,
usedSlugs: Set<string>,
): AliasEntry {
const primaryPool = getCharacterPool(resource.lcrCents);
const orderedPools = [primaryPool, ICONIC_CHARACTERS, WELL_KNOWN_CHARACTERS, SUPPORTING_CHARACTERS];
const offset = hashInt(`${config.seed}:${resource.id}`);
for (const pool of orderedPools) {
for (let i = 0; i < pool.length; i += 1) {
const candidate = pool[(offset + i) % pool.length]!;
if (!usedSlugs.has(candidate.slug)) {
return candidate;
}
}
}
for (const candidate of COMPOSITE_CHARACTERS) {
if (!usedSlugs.has(candidate.slug)) {
return candidate;
}
}
const fallbackWords = [...COMPOSITE_GIVEN_NAMES, ...COMPOSITE_SURNAMES];
const firstOffset = hashInt(`${config.seed}:${resource.id}:fallback:first`);
const secondOffset = hashInt(`${config.seed}:${resource.id}:fallback:second`);
for (let i = 0; i < fallbackWords.length; i += 1) {
for (let j = 0; j < fallbackWords.length; j += 1) {
const first = fallbackWords[(firstOffset + i) % fallbackWords.length]!;
const second = fallbackWords[(secondOffset + j) % fallbackWords.length]!;
if (first === second) {
continue;
}
const candidate = buildAliasEntries([`${first} ${second}`])[0];
if (candidate && !usedSlugs.has(candidate.slug)) {
return candidate;
}
}
}
throw new Error("Unable to generate a unique anonymization alias without digits");
}
async function loadDirectoryResources(
db: Pick<PrismaClient, "resource">,
): Promise<DirectoryResource[]> {
if (!("resource" in db) || typeof db.resource?.findMany !== "function") {
return [];
}
const resources = await db.resource.findMany({
select: {
id: true,
eid: true,
displayName: true,
email: true,
lcrCents: true,
},
orderBy: { id: "asc" },
});
return resources.map((resource) => ({
...resource,
lcrCents: resource.lcrCents ?? 0,
}));
}
export async function getAnonymizationConfig(
db: Pick<PrismaClient, "systemSettings">,
): Promise<AnonymizationConfig> {
if (!("systemSettings" in db) || typeof db.systemSettings?.findUnique !== "function") {
return {
enabled: false,
domain: DEFAULT_ANONYMIZATION_DOMAIN,
seed: DEFAULT_ANONYMIZATION_SEED,
mode: "global",
};
}
const settings = await db.systemSettings.findUnique({
where: { id: "singleton" },
select: {
anonymizationEnabled: true,
anonymizationDomain: true,
anonymizationSeed: true,
anonymizationMode: true,
},
});
return {
enabled: settings?.anonymizationEnabled ?? false,
domain: settings?.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN,
seed: settings?.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED,
mode: "global",
};
}
export async function getAnonymizationDirectory(
db: Pick<PrismaClient, "systemSettings" | "resource">,
): Promise<AnonymizationDirectory | null> {
if (!("systemSettings" in db) || typeof db.systemSettings?.findUnique !== "function") {
return null;
}
const settings = await db.systemSettings.findUnique({
where: { id: "singleton" },
select: {
anonymizationEnabled: true,
anonymizationDomain: true,
anonymizationSeed: true,
anonymizationMode: true,
anonymizationAliases: true,
},
});
const config: AnonymizationConfig = {
enabled: settings?.anonymizationEnabled ?? false,
domain: settings?.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN,
seed: settings?.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED,
mode: "global",
};
if (!config.enabled) {
return null;
}
const resources = await loadDirectoryResources(db);
const usedSlugs = new Set<string>();
const byResourceId = new Map<string, ResourceAlias>();
const byAliasEid = new Map<string, string>();
const storedAliases = parseStoredAliases(settings?.anonymizationAliases);
let aliasesChanged = false;
for (const [resourceId, storedAlias] of Object.entries(storedAliases)) {
const normalizedEid = normalize(storedAlias.eid);
if (!normalizedEid || usedSlugs.has(normalizedEid) || !isStoredAliasEntryValid(storedAlias)) {
delete storedAliases[resourceId];
aliasesChanged = true;
continue;
}
usedSlugs.add(normalizedEid);
byResourceId.set(resourceId, toAlias(storedAlias, config.domain));
byAliasEid.set(normalizedEid, resourceId);
}
const rankedResources = [...resources].sort((left, right) => {
if (right.lcrCents !== left.lcrCents) {
return right.lcrCents - left.lcrCents;
}
return left.id.localeCompare(right.id);
});
for (const resource of rankedResources) {
const existing = byResourceId.get(resource.id);
if (existing) {
continue;
}
const alias = pickUniqueAlias(resource, config, usedSlugs);
const entry = {
displayName: alias.name,
eid: alias.slug,
email: `${alias.slug}@${config.domain}`,
};
usedSlugs.add(normalize(alias.slug));
byResourceId.set(resource.id, entry);
byAliasEid.set(normalize(alias.slug), resource.id);
storedAliases[resource.id] = {
displayName: alias.name,
eid: alias.slug,
};
aliasesChanged = true;
}
if (aliasesChanged && typeof db.systemSettings.update === "function") {
await db.systemSettings.update({
where: { id: "singleton" },
data: { anonymizationAliases: storedAliases },
});
}
return {
config,
byResourceId,
byAliasEid,
};
}
export function anonymizeResource<T extends ResourceIdentity>(
resource: T,
directory: AnonymizationDirectory | null,
): T {
if (!directory) {
return resource;
}
const alias = directory.byResourceId.get(resource.id);
if (!alias) {
return resource;
}
return {
...resource,
displayName: alias.displayName,
eid: alias.eid,
...(Object.prototype.hasOwnProperty.call(resource, "email") ? { email: alias.email } : {}),
};
}
export function anonymizeResources<T extends ResourceIdentity>(
resources: T[],
directory: AnonymizationDirectory | null,
): T[] {
if (!directory) {
return resources;
}
return resources.map((resource) => anonymizeResource(resource, directory));
}
export function anonymizeUser<T extends UserIdentity>(
user: T,
directory: AnonymizationDirectory | null,
): T {
if (!directory) {
return user;
}
const stableKey = normalize(user.id) || normalize(user.email) || normalize(user.name);
if (!stableKey) {
return user;
}
const index = hashInt(`${directory.config.seed}:user:${stableKey}`) % USER_CHARACTER_POOL.length;
const alias = USER_CHARACTER_POOL[index]!;
return {
...user,
name: alias.name,
...(Object.prototype.hasOwnProperty.call(user, "email")
? { email: `${alias.slug}@${directory.config.domain}` }
: {}),
};
}
export function anonymizeSearchMatches(
resource: DirectoryIdentity,
alias: ResourceAlias | undefined,
search: string,
): boolean {
const query = normalize(search);
if (!query) {
return true;
}
const haystack = [
resource.displayName,
resource.eid,
resource.email,
alias?.displayName,
alias?.eid,
alias?.email,
]
.filter(Boolean)
.map((value) => normalize(value));
return haystack.some((value) => value.includes(query));
}
export function resolveResourceIdsByDisplayedEids(
resources: DirectoryIdentity[],
directory: AnonymizationDirectory | null,
requestedEids: string[],
): string[] {
if (requestedEids.length === 0) {
return [];
}
const realByEid = new Map(resources.map((resource) => [normalize(resource.eid), resource.id]));
const ids = requestedEids
.map((value) => {
const normalized = normalize(value);
return directory?.byAliasEid.get(normalized) ?? realByEid.get(normalized) ?? null;
})
.filter((value): value is string => value !== null);
return [...new Set(ids)];
}
@@ -10,10 +10,11 @@ import {
type AssignmentSlice,
} from "@planarchy/engine";
import type { SpainScheduleRule } from "@planarchy/shared";
import { listAssignmentBookings } from "@planarchy/application";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application";
import { VacationStatus } from "@planarchy/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
export const chargeabilityReportRouter = createTRPCRouter({
getReport: controllerProcedure
@@ -24,10 +25,11 @@ export const chargeabilityReportRouter = createTRPCRouter({
orgUnitId: z.string().optional(),
managementLevelGroupId: z.string().optional(),
countryId: z.string().optional(),
includeProposed: z.boolean().default(false),
}),
)
.query(async ({ ctx, input }) => {
const { startMonth, endMonth } = input;
const { startMonth, endMonth, includeProposed } = input;
// Parse month range
const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number];
@@ -100,7 +102,8 @@ export const chargeabilityReportRouter = createTRPCRouter({
// Normalize bookings to a common shape
const assignments = allBookings
.filter((b) => b.resourceId !== null)
.filter((booking) => booking.resourceId !== null)
.filter((booking) => isChargeabilityActualBooking(booking, includeProposed))
.map((b) => ({
resourceId: b.resourceId!,
startDate: b.startDate,
@@ -171,8 +174,6 @@ export const chargeabilityReportRouter = createTRPCRouter({
// Build assignment slices for this month
const slices: AssignmentSlice[] = [];
for (const a of resourceAssignments) {
// Skip DRAFT projects
if (a.project.status === "DRAFT" || a.project.status === "CANCELLED") continue;
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
if (workingDays <= 0) continue;
@@ -236,9 +237,11 @@ export const chargeabilityReportRouter = createTRPCRouter({
};
});
const directory = await getAnonymizationDirectory(ctx.db);
return {
monthKeys,
resources: resourceRows,
resources: anonymizeResources(resourceRows, directory),
groupTotals,
};
}),
+33 -13
View File
@@ -7,6 +7,7 @@ import {
getDashboardPeakTimes,
getDashboardTopValueResources,
} from "@planarchy/application";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
export const dashboardRouter = createTRPCRouter({
getOverview: protectedProcedure.query(({ ctx }) => getDashboardOverview(ctx.db)),
@@ -31,13 +32,17 @@ export const dashboardRouter = createTRPCRouter({
getTopValueResources: protectedProcedure
.input(z.object({ limit: z.number().int().min(1).max(50).default(10) }))
.query(({ ctx, input }) =>
getDashboardTopValueResources(ctx.db, {
limit: input.limit,
userRole:
(ctx.session.user as { role?: string } | undefined)?.role ?? "USER",
}),
),
.query(async ({ ctx, input }) => {
const [resources, directory] = await Promise.all([
getDashboardTopValueResources(ctx.db, {
limit: input.limit,
userRole:
(ctx.session.user as { role?: string } | undefined)?.role ?? "USER",
}),
getAnonymizationDirectory(ctx.db),
]);
return anonymizeResources(resources, directory);
}),
getDemand: protectedProcedure
.input(
@@ -58,14 +63,29 @@ export const dashboardRouter = createTRPCRouter({
getChargeabilityOverview: controllerProcedure
.input(
z.object({
includeProposed: z.boolean().default(false),
topN: z.number().int().min(1).max(50).default(10),
watchlistThreshold: z.number().default(15),
countryIds: z.array(z.string()).optional(),
departed: z.boolean().optional(),
}),
)
.query(({ ctx, input }) =>
getDashboardChargeabilityOverview(ctx.db, {
topN: input.topN,
watchlistThreshold: input.watchlistThreshold,
}),
),
.query(async ({ ctx, input }) => {
const [overview, directory] = await Promise.all([
getDashboardChargeabilityOverview(ctx.db, {
includeProposed: input.includeProposed,
topN: input.topN,
watchlistThreshold: input.watchlistThreshold,
...(input.countryIds !== undefined ? { countryIds: input.countryIds } : {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
}),
getAnonymizationDirectory(ctx.db),
]);
return {
...overview,
top: anonymizeResources(overview.top, directory),
watchlist: anonymizeResources(overview.watchlist, directory),
};
}),
});
+20
View File
@@ -38,6 +38,10 @@ export const settingsRouter = createTRPCRouter({
smtpFrom: settings?.smtpFrom ?? null,
smtpTls: settings?.smtpTls ?? true,
hasSmtpPassword: !!settings?.smtpPassword,
// Global anonymization
anonymizationEnabled: settings?.anonymizationEnabled ?? false,
anonymizationDomain: settings?.anonymizationDomain ?? "superhartmut.de",
anonymizationMode: settings?.anonymizationMode ?? "global",
// Vacation defaults
vacationDefaultDays: settings?.vacationDefaultDays ?? 28,
};
@@ -75,6 +79,11 @@ export const settingsRouter = createTRPCRouter({
smtpPassword: z.string().optional(),
smtpFrom: z.string().email().optional().or(z.literal("")),
smtpTls: z.boolean().optional(),
// Global anonymization
anonymizationEnabled: z.boolean().optional(),
anonymizationDomain: z.string().trim().min(1).optional(),
anonymizationSeed: z.string().trim().min(1).optional().or(z.literal("")),
anonymizationMode: z.enum(["global"]).optional(),
// Vacation
vacationDefaultDays: z.number().int().min(0).max(365).optional(),
}),
@@ -107,6 +116,17 @@ export const settingsRouter = createTRPCRouter({
if (input.smtpPassword !== undefined) data.smtpPassword = input.smtpPassword || null;
if (input.smtpFrom !== undefined) data.smtpFrom = input.smtpFrom || null;
if (input.smtpTls !== undefined) data.smtpTls = input.smtpTls;
// Global anonymization
if (input.anonymizationEnabled !== undefined) data.anonymizationEnabled = input.anonymizationEnabled;
if (input.anonymizationDomain !== undefined) data.anonymizationDomain = input.anonymizationDomain || "superhartmut.de";
if (input.anonymizationSeed !== undefined) {
data.anonymizationSeed = input.anonymizationSeed || null;
data.anonymizationAliases = null;
}
if (input.anonymizationMode !== undefined) {
data.anonymizationMode = input.anonymizationMode;
data.anonymizationAliases = null;
}
// Vacation
if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays;
+1
View File
@@ -14,6 +14,7 @@
"@planarchy/db": "workspace:*",
"@planarchy/engine": "workspace:*",
"@planarchy/shared": "workspace:*",
"@planarchy/staffing": "workspace:*",
"@trpc/server": "^11.0.0",
"xlsx": "^0.18.5"
},
@@ -21,6 +21,7 @@ describe("listAssignmentBookings", () => {
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "CHARGEABLE",
dynamicFields: null,
},
resource: {
id: "res_1",
@@ -43,6 +44,7 @@ describe("listAssignmentBookings", () => {
shortCode: "BRAVO",
status: "DRAFT",
orderType: "INTERNAL",
dynamicFields: null,
},
resource: {
id: "res_2",
@@ -75,6 +77,7 @@ describe("listAssignmentBookings", () => {
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "CHARGEABLE",
dynamicFields: null,
},
resource: {
id: "res_1",
@@ -97,6 +100,7 @@ describe("listAssignmentBookings", () => {
shortCode: "BRAVO",
status: "DRAFT",
orderType: "INTERNAL",
dynamicFields: null,
},
resource: {
id: "res_2",
@@ -133,7 +137,14 @@ describe("listAssignmentBookings", () => {
dailyCostCents: true,
status: true,
project: {
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
select: {
id: true,
name: true,
shortCode: true,
status: true,
orderType: true,
dynamicFields: true,
},
},
resource: {
select: { id: true, displayName: true, chapter: true },
@@ -186,7 +197,14 @@ describe("listAssignmentBookings", () => {
dailyCostCents: true,
status: true,
project: {
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
select: {
id: true,
name: true,
shortCode: true,
status: true,
orderType: true,
dynamicFields: true,
},
},
resource: {
select: { id: true, displayName: true, chapter: true },
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { commitDispoImportBatch } from "../index.js";
import { deriveTbdDispoProjectIdentity } from "../use-cases/dispo-import/tbd-projects.js";
function createCommitDb(overrides: Record<string, unknown> = {}) {
const tx = {
@@ -331,6 +332,162 @@ describe("commitDispoImportBatch", () => {
);
});
it("commits [tbd] rows as provisional projects when explicitly enabled", async () => {
const { db, tx } = createCommitDb();
const rawToken = "[DAI] 590 C AMG GT Stills [tbd]{CH} HB_";
const tbdProject = deriveTbdDispoProjectIdentity(rawToken, "Chg");
db.stagedUnresolvedRecord.findMany.mockResolvedValue([
{
id: "unresolved_1",
recordType: "PROJECT",
message: `Planning token "${rawToken}" references [tbd] and requires project resolution`,
resolutionHint: "Resolve [tbd] rows to a real WBS/project before commit",
normalizedData: { rawToken },
status: "UNRESOLVED",
},
]);
tx.stagedResource.findMany.mockResolvedValue([
{
id: "sr_roster",
sourceKind: "ROSTER",
canonicalExternalId: "h.noerenberg",
displayName: "Hartmut Norenberg",
email: "h.noerenberg@accenture.com",
chapter: "Art Direction",
chargeabilityTarget: null,
clientUnitName: null,
countryCode: "DE",
fte: 1,
lcrCents: 1000,
managementLevelGroupName: null,
managementLevelName: null,
metroCityName: null,
resourceType: "EMPLOYEE",
roleTokens: ["AD"],
ucrCents: 1500,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
rawPayload: {},
warnings: [],
},
]);
tx.stagedProject.findMany.mockResolvedValue([
{
id: "sp_tbd_1",
shortCode: tbdProject.shortCode,
projectKey: tbdProject.projectKey,
name: tbdProject.name,
clientCode: "DAI",
utilizationCategoryCode: "Chg",
allocationType: "EXT",
orderType: "CHARGEABLE",
winProbability: 100,
isInternal: false,
isTbd: true,
startDate: new Date("2026-02-03T00:00:00.000Z"),
endDate: new Date("2026-02-04T00:00:00.000Z"),
warnings: [],
rawPayload: {
rawTokens: [rawToken],
},
},
]);
tx.stagedAssignment.findMany.mockResolvedValue([
{
id: "sa_tbd_1",
resourceExternalId: "h.noerenberg",
projectKey: null,
assignmentDate: new Date("2026-02-03T00:00:00.000Z"),
startDate: new Date("2026-02-03T00:00:00.000Z"),
endDate: new Date("2026-02-03T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
roleToken: "AD",
roleName: "Art Director",
utilizationCategoryCode: "Chg",
isInternal: false,
isUnassigned: false,
isTbd: true,
rawPayload: {
rawToken,
},
},
{
id: "sa_tbd_2",
resourceExternalId: "h.noerenberg",
projectKey: null,
assignmentDate: new Date("2026-02-04T00:00:00.000Z"),
startDate: new Date("2026-02-04T00:00:00.000Z"),
endDate: new Date("2026-02-04T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
roleToken: "AD",
roleName: "Art Director",
utilizationCategoryCode: "Chg",
isInternal: false,
isUnassigned: false,
isTbd: true,
rawPayload: {
rawToken,
},
},
]);
const result = await commitDispoImportBatch(db as never, {
importBatchId: "batch_1",
importTbdProjects: true,
});
expect(result).toEqual({
batchId: "batch_1",
counts: {
committedAssignments: 1,
committedProjects: 1,
committedResources: 1,
committedVacations: 0,
updatedEntitlements: 0,
updatedResourceAvailabilities: 0,
upsertedResourceRoles: 2,
},
unresolved: {
blocked: 0,
skippedTbd: 1,
},
});
expect(tx.project.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { shortCode: tbdProject.shortCode },
create: expect.objectContaining({
name: tbdProject.name,
shortCode: tbdProject.shortCode,
status: "DRAFT",
}),
}),
);
expect(tx.assignment.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
endDate: new Date("2026-02-04T00:00:00.000Z"),
startDate: new Date("2026-02-03T00:00:00.000Z"),
}),
}),
);
expect(tx.stagedProject.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { importBatchId: "batch_1" },
}),
);
});
it("blocks commit when non-[tbd] unresolved rows remain", async () => {
const { db } = createCommitDb();
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
getDashboardChargeabilityOverview,
getDashboardDemand,
getDashboardOverview,
getDashboardPeakTimes,
@@ -26,6 +27,7 @@ describe("dashboard use-cases", () => {
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "FIXED",
dynamicFields: null,
},
resource: {
id: "res_1",
@@ -342,6 +344,175 @@ describe("dashboard use-cases", () => {
);
});
it("keeps proposed allocations out of actual chargeability by default but can include them", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assign_1",
projectId: "proj_1",
resourceId: "res_1",
status: "PROPOSED",
startDate: new Date("2026-03-03T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 0,
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "FIXED",
},
resource: {
id: "res_1",
displayName: "Alice",
chapter: "CGI",
},
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
eid: "alice",
displayName: "Alice",
chapter: "CGI",
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
},
]),
},
};
const strict = await getDashboardChargeabilityOverview(db as never, {
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
});
const withProposed = await getDashboardChargeabilityOverview(db as never, {
includeProposed: true,
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
});
expect(strict.top[0]?.actualChargeability).toBe(0);
expect(strict.top[0]?.expectedChargeability).toBe(5);
expect(withProposed.top[0]?.actualChargeability).toBe(5);
expect(withProposed.top[0]?.expectedChargeability).toBe(5);
});
it("filters chargeability overview by departed state and country", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
eid: "alice",
displayName: "Alice",
chapter: "CGI",
countryId: "country_de",
departed: false,
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
},
]),
},
};
const result = await getDashboardChargeabilityOverview(db as never, {
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
countryIds: ["country_de"],
departed: false,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
countryId: { in: ["country_de"] },
departed: false,
}),
}),
);
expect(result.top).toHaveLength(1);
expect(result.top[0]).toEqual(
expect.objectContaining({
id: "res_1",
countryId: "country_de",
departed: false,
}),
);
});
it("includes imported TBD draft projects in actual chargeability only when proposed work is enabled", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assign_tbd",
projectId: "proj_tbd",
resourceId: "res_1",
status: "PROPOSED",
startDate: new Date("2026-03-03T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 0,
project: {
id: "proj_tbd",
name: "TBD: AMG",
shortCode: "TBD-AMG",
status: "DRAFT",
orderType: "CLIENT",
dynamicFields: { dispoImport: { isTbd: true } },
},
resource: {
id: "res_1",
displayName: "Alice",
chapter: "CGI",
},
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
eid: "alice",
displayName: "Alice",
chapter: "CGI",
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
},
]),
},
};
const strict = await getDashboardChargeabilityOverview(db as never, {
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
});
const withProposed = await getDashboardChargeabilityOverview(db as never, {
includeProposed: true,
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
});
expect(strict.top[0]?.actualChargeability).toBe(0);
expect(strict.top[0]?.expectedChargeability).toBe(5);
expect(withProposed.top[0]?.actualChargeability).toBe(5);
expect(withProposed.top[0]?.expectedChargeability).toBe(5);
});
it("returns distinct resource counts for chapter demand grouping", async () => {
const db = {
demandRequirement: {
@@ -640,6 +640,11 @@ describe("dispo import", () => {
isInternal: false,
utilizationCategoryCode: "Chg",
}),
expect.objectContaining({
isTbd: true,
isInternal: false,
shortCode: expect.stringMatching(/^TBD-/),
}),
]),
}),
);
+10
View File
@@ -22,6 +22,11 @@ export {
type AssignmentBookingWithFallback,
type ListAssignmentBookingsInput,
} from "./use-cases/allocation/list-assignment-bookings.js";
export {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
isImportedTbdDraftProject,
} from "./use-cases/allocation/chargeability-bookings.js";
export {
countPlanningEntries,
type CountPlanningEntriesInput,
@@ -93,6 +98,11 @@ export {
type EstimateListItem,
} from "./use-cases/estimate/index.js";
export {
recomputeResourceValueScores,
type RecomputeResourceValueScoresInput,
} from "./use-cases/resource/index.js";
export {
assessDispoImportReadiness,
parseMandatoryDispoReferenceWorkbook,
@@ -0,0 +1,53 @@
import type { Prisma } from "@planarchy/db";
import type { AssignmentBookingWithFallback } from "./list-assignment-bookings.js";
type ChargeabilityProjectLike = AssignmentBookingWithFallback["project"];
type ChargeabilityBookingLike = Pick<AssignmentBookingWithFallback, "status" | "project">;
function asObject(value: Prisma.JsonValue | null | undefined): Record<string, unknown> | null {
if (value === null || value === undefined || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
export function isImportedTbdDraftProject(project: ChargeabilityProjectLike): boolean {
if (project.status !== "DRAFT") {
return false;
}
const dynamicFields = asObject(project.dynamicFields);
const dispoImport = asObject(dynamicFields?.["dispoImport"] as Prisma.JsonValue | undefined);
return dispoImport?.["isTbd"] === true;
}
export function isChargeabilityRelevantProject(
project: ChargeabilityProjectLike,
includeProposed: boolean,
): boolean {
if (project.status === "ACTIVE") {
return true;
}
if (project.status === "CANCELLED") {
return false;
}
return includeProposed && isImportedTbdDraftProject(project);
}
export function isChargeabilityActualBooking(
booking: ChargeabilityBookingLike,
includeProposed: boolean,
): boolean {
if (!isChargeabilityRelevantProject(booking.project, includeProposed)) {
return false;
}
return (
booking.status === "CONFIRMED" ||
booking.status === "ACTIVE" ||
(includeProposed && booking.status === "PROPOSED")
);
}
@@ -25,6 +25,7 @@ export interface AssignmentBookingWithFallback {
shortCode: string;
status: string;
orderType: string;
dynamicFields: Prisma.JsonValue | null;
};
resource: {
id: string;
@@ -67,7 +68,14 @@ export async function listAssignmentBookings(
dailyCostCents: true,
status: true,
project: {
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
select: {
id: true,
name: true,
shortCode: true,
status: true,
orderType: true,
dynamicFields: true,
},
},
resource: {
select: { id: true, displayName: true, chapter: true },
@@ -1,11 +1,18 @@
import type { PrismaClient } from "@planarchy/db";
import { computeChargeability } from "@planarchy/engine";
import type { WeekdayAvailability } from "@planarchy/shared";
import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
} from "../allocation/chargeability-bookings.js";
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
export interface GetDashboardChargeabilityOverviewInput {
includeProposed?: boolean;
topN: number;
watchlistThreshold: number;
countryIds?: string[];
departed?: boolean;
now?: Date;
}
@@ -18,12 +25,20 @@ export async function getDashboardChargeabilityOverview(
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const resources = await db.resource.findMany({
where: { isActive: true },
where: {
isActive: true,
...(input.countryIds && input.countryIds.length > 0
? { countryId: { in: input.countryIds } }
: {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
},
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
countryId: true,
departed: true,
chargeabilityTarget: true,
availability: true,
},
@@ -37,11 +52,11 @@ export async function getDashboardChargeabilityOverview(
const stats = resources.map((resource) => {
const availability = resource.availability as unknown as WeekdayAvailability;
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const actualAllocations = resourceBookings.filter(
(booking) =>
(booking.status === "CONFIRMED" || booking.status === "ACTIVE") &&
booking.project.status !== "DRAFT" &&
booking.project.status !== "CANCELLED",
const actualAllocations = resourceBookings.filter((booking) =>
isChargeabilityActualBooking(booking, input.includeProposed === true),
);
const expectedAllocations = resourceBookings.filter(
(booking) => isChargeabilityRelevantProject(booking.project, true),
);
const actual = computeChargeability(
availability,
@@ -51,7 +66,7 @@ export async function getDashboardChargeabilityOverview(
);
const expected = computeChargeability(
availability,
resourceBookings,
expectedAllocations,
start,
end,
);
@@ -61,6 +76,8 @@ export async function getDashboardChargeabilityOverview(
eid: resource.eid,
displayName: resource.displayName,
chapter: resource.chapter,
countryId: resource.countryId,
departed: resource.departed,
chargeabilityTarget: resource.chargeabilityTarget,
actualChargeability: actual.chargeability,
expectedChargeability: expected.chargeability,
@@ -69,16 +86,14 @@ export async function getDashboardChargeabilityOverview(
return {
top: [...stats]
.sort((left, right) => right.actualChargeability - left.actualChargeability)
.slice(0, input.topN),
.sort((left, right) => right.actualChargeability - left.actualChargeability),
watchlist: [...stats]
.filter(
(resource) =>
resource.actualChargeability <
resource.chargeabilityTarget - input.watchlistThreshold,
)
.sort((left, right) => left.actualChargeability - right.actualChargeability)
.slice(0, input.topN),
.sort((left, right) => left.actualChargeability - right.actualChargeability),
month: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`,
};
}
@@ -573,10 +573,10 @@ export async function parseDispoPlanningWorkbook(
const existing = availabilityRules.get(key);
if (existing) {
const nextAvailability = buildAvailabilityAccumulator(column, metadata, rawToken, {
availableHours: naHandling.availableHours,
percentage: naHandling.percentage,
ruleType: naHandling.ruleType,
warning: naHandling.warning,
...(naHandling.availableHours !== undefined ? { availableHours: naHandling.availableHours } : {}),
...(naHandling.percentage !== undefined ? { percentage: naHandling.percentage } : {}),
...(naHandling.ruleType !== undefined ? { ruleType: naHandling.ruleType } : {}),
...(naHandling.warning !== undefined ? { warning: naHandling.warning } : {}),
});
existing.availableHours = nextAvailability.availableHours;
existing.percentage = nextAvailability.percentage;
@@ -585,10 +585,10 @@ export async function parseDispoPlanningWorkbook(
}
} else {
const availabilityRule = buildAvailabilityAccumulator(column, metadata, rawToken, {
availableHours: naHandling.availableHours,
percentage: naHandling.percentage,
ruleType: naHandling.ruleType,
warning: naHandling.warning,
...(naHandling.availableHours !== undefined ? { availableHours: naHandling.availableHours } : {}),
...(naHandling.percentage !== undefined ? { percentage: naHandling.percentage } : {}),
...(naHandling.ruleType !== undefined ? { ruleType: naHandling.ruleType } : {}),
...(naHandling.warning !== undefined ? { warning: naHandling.warning } : {}),
});
availabilityRule.sourceRow = rowNumber;
availabilityRules.set(key, availabilityRule);
@@ -1,11 +1,4 @@
import XLSXModule from "xlsx";
const XLSX =
(
XLSXModule as typeof import("xlsx") & {
default?: typeof import("xlsx");
}
).default ?? (XLSXModule as typeof import("xlsx"));
import XLSX from "xlsx";
export type WorksheetCellValue = boolean | Date | number | string | null;
export type WorksheetMatrix = WorksheetCellValue[][];
@@ -2,6 +2,11 @@ import type { Prisma } from "@planarchy/db";
import { AllocationType, DispoImportSourceKind, OrderType, StagedRecordStatus } from "@planarchy/db";
import { DISPO_INTERNAL_PROJECT_BUCKETS } from "@planarchy/shared";
import { parseDispoPlanningWorkbook } from "./parse-dispo-matrix.js";
import {
classifyDispoProject,
deriveResolvedDispoProjectIdentity,
deriveTbdDispoProjectIdentity,
} from "./tbd-projects.js";
import {
DISPO_PLANNING_SHEET,
ensureImportBatch,
@@ -32,34 +37,6 @@ interface ResolvedStagedProject {
winProbability: number | null;
}
function extractBracketTokens(token: string): string[] {
return Array.from(token.matchAll(/\[([^\]]+)\]/g), (match) => match[1]?.trim() ?? "").filter(Boolean);
}
function extractClientCode(token: string): string | null {
const candidates = extractBracketTokens(token).filter(
(entry) =>
entry.length > 0 &&
!entry.startsWith("_") &&
!/^\d+$/.test(entry) &&
entry.toLowerCase() !== "tbd",
);
return candidates[0] ?? null;
}
function deriveProjectName(token: string, fallbackProjectKey: string): string {
const normalized = token
.replace(/^(2D|3D|PM|AD)\s+/i, "")
.replace(/\[[^\]]+\]/g, " ")
.replace(/\{[^}]+\}/g, " ")
.replace(/\s+(?:HB|SB)_?\s*$/i, " ")
.replace(/\s+/g, " ")
.trim();
return normalized.length > 0 ? normalized : `Project ${fallbackProjectKey}`;
}
function updateDateRange(project: ResolvedStagedProject, assignmentDate: Date) {
if (assignmentDate < project.startDate) {
project.startDate = assignmentDate;
@@ -131,7 +108,7 @@ export async function stageDispoProjects(
continue;
}
if (assignment.isTbd || assignment.isUnassigned) {
if (assignment.isUnassigned) {
continue;
}
@@ -153,32 +130,27 @@ export async function stageDispoProjects(
continue;
}
if (!assignment.projectKey) {
if (!assignment.projectKey && !assignment.isTbd) {
continue;
}
const derivedClientCode = extractClientCode(assignment.rawToken);
const derivedName = deriveProjectName(assignment.rawToken, assignment.projectKey);
const shortCode = assignment.projectKey;
const existing = projects.get(assignment.projectKey);
const identity = assignment.isTbd
? deriveTbdDispoProjectIdentity(assignment.rawToken, assignment.utilizationCategoryCode)
: deriveResolvedDispoProjectIdentity(assignment.rawToken, assignment.projectKey!);
const classification = classifyDispoProject(assignment.utilizationCategoryCode);
const existing = projects.get(identity.projectKey);
if (!existing) {
projects.set(assignment.projectKey, {
allocationType: assignment.utilizationCategoryCode === "Chg"
? AllocationType.EXT
: AllocationType.INT,
clientCode: derivedClientCode,
projects.set(identity.projectKey, {
allocationType: classification.allocationType,
clientCode: identity.clientCode,
isInternal: false,
isTbd: false,
name: derivedName,
orderType: assignment.utilizationCategoryCode === "Chg"
? OrderType.CHARGEABLE
: assignment.utilizationCategoryCode === "BD"
? OrderType.BD
: OrderType.INTERNAL,
projectKey: assignment.projectKey,
isTbd: assignment.isTbd,
name: identity.name,
orderType: classification.orderType,
projectKey: identity.projectKey,
rawTokens: new Set<string>([assignment.rawToken]),
shortCode,
shortCode: identity.shortCode,
sourceColumn: assignment.sourceColumn,
sourceRow: assignment.sourceRow,
startDate: assignment.assignmentDate,
@@ -193,19 +165,19 @@ export async function stageDispoProjects(
updateDateRange(existing, assignment.assignmentDate);
existing.rawTokens.add(assignment.rawToken);
if (derivedClientCode && existing.clientCode && existing.clientCode !== derivedClientCode) {
if (identity.clientCode && existing.clientCode && existing.clientCode !== identity.clientCode) {
existing.warnings.add(
`Conflicting client codes for project ${assignment.projectKey}: ${existing.clientCode} vs ${derivedClientCode}`,
`Conflicting client codes for project ${identity.projectKey}: ${existing.clientCode} vs ${identity.clientCode}`,
);
}
if (!existing.clientCode && derivedClientCode) {
existing.clientCode = derivedClientCode;
if (!existing.clientCode && identity.clientCode) {
existing.clientCode = identity.clientCode;
}
if (normalizeText(existing.name) !== normalizeText(derivedName)) {
if (normalizeText(existing.name) !== normalizeText(identity.name)) {
existing.warnings.add(
`Multiple project names observed for ${assignment.projectKey}; using "${existing.name}"`,
`Multiple project names observed for ${identity.projectKey}; using "${existing.name}"`,
);
}
@@ -215,7 +187,7 @@ export async function stageDispoProjects(
existing.utilizationCategoryCode !== assignment.utilizationCategoryCode
) {
existing.warnings.add(
`Conflicting utilization categories for ${assignment.projectKey}: ${existing.utilizationCategoryCode} vs ${assignment.utilizationCategoryCode}`,
`Conflicting utilization categories for ${identity.projectKey}: ${existing.utilizationCategoryCode} vs ${assignment.utilizationCategoryCode}`,
);
}
@@ -229,7 +201,7 @@ export async function stageDispoProjects(
existing.winProbability !== assignment.winProbability
) {
existing.warnings.add(
`Conflicting win probabilities for ${assignment.projectKey}: ${existing.winProbability} vs ${assignment.winProbability}`,
`Conflicting win probabilities for ${identity.projectKey}: ${existing.winProbability} vs ${assignment.winProbability}`,
);
}
@@ -0,0 +1,116 @@
import { createHash } from "node:crypto";
import { AllocationType, OrderType } from "@planarchy/db";
function extractBracketTokens(token: string): string[] {
return Array.from(
token.matchAll(/\[([^\]]+)\]/g),
(match) => match[1]?.trim() ?? "",
).filter(Boolean);
}
function extractClientCode(token: string): string | null {
const candidates = extractBracketTokens(token).filter(
(entry) =>
entry.length > 0 &&
!entry.startsWith("_") &&
!/^\d+$/.test(entry) &&
entry.toLowerCase() !== "tbd",
);
return candidates[0] ?? null;
}
function extractProjectLabel(token: string): string | null {
const stripped = token
.replace(/^(2D|3D|PM|AD)\s+/i, "")
.replace(/\[[^\]]+\]/g, " ")
.replace(/\{[^}]+\}/g, " ")
.replace(/\s+(?:HB|SB)_?\s*$/i, " ")
.replace(/\s+/g, " ")
.trim();
return stripped.length > 0 ? stripped : null;
}
function slugifyFragment(value: string): string {
return value
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function shortSegment(value: string | null | undefined, fallback: string, maxLength: number): string {
const normalized = slugifyFragment(value ?? "");
return (normalized.length > 0 ? normalized : fallback).slice(0, maxLength);
}
export interface DerivedDispoProjectIdentity {
clientCode: string | null;
name: string;
projectKey: string;
shortCode: string;
}
export interface DerivedDispoProjectClassification {
allocationType: AllocationType;
orderType: OrderType;
}
export function classifyDispoProject(utilizationCategoryCode: string | null): DerivedDispoProjectClassification {
if (utilizationCategoryCode === "Chg") {
return {
allocationType: AllocationType.EXT,
orderType: OrderType.CHARGEABLE,
};
}
if (utilizationCategoryCode === "BD") {
return {
allocationType: AllocationType.INT,
orderType: OrderType.BD,
};
}
return {
allocationType: AllocationType.INT,
orderType: OrderType.INTERNAL,
};
}
export function deriveResolvedDispoProjectIdentity(
rawToken: string,
fallbackProjectKey: string,
): DerivedDispoProjectIdentity {
const name = extractProjectLabel(rawToken) ?? `Project ${fallbackProjectKey}`;
return {
clientCode: extractClientCode(rawToken),
name,
projectKey: fallbackProjectKey,
shortCode: fallbackProjectKey,
};
}
export function deriveTbdDispoProjectIdentity(
rawToken: string,
utilizationCategoryCode: string | null,
): DerivedDispoProjectIdentity {
const clientCode = extractClientCode(rawToken);
const name = extractProjectLabel(rawToken) ?? "Unresolved Dispo Work";
const clientSegment = shortSegment(clientCode, "GEN", 10);
const utilizationSegment = shortSegment(utilizationCategoryCode, "UNK", 6);
const labelSegment = shortSegment(name, "UNRESOLVED-WORK", 24);
const hash = createHash("sha1")
.update(`${rawToken.trim().toLowerCase()}|${utilizationCategoryCode ?? ""}`)
.digest("hex")
.slice(0, 8)
.toUpperCase();
const shortCode = `TBD-${clientSegment}-${utilizationSegment}-${labelSegment}-${hash}`;
return {
clientCode,
name: `TBD: ${name}`,
projectKey: shortCode,
shortCode,
};
}
@@ -0,0 +1,4 @@
export {
recomputeResourceValueScores,
type RecomputeResourceValueScoresInput,
} from "./recompute-resource-value-scores.js";
@@ -0,0 +1,111 @@
import type { PrismaClient } from "@planarchy/db";
import { computeValueScore } from "@planarchy/staffing";
import { VALUE_SCORE_WEIGHTS } from "@planarchy/shared";
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
type ResourceValueScoreDbClient = Partial<Pick<PrismaClient, "systemSettings">> &
Pick<PrismaClient, "assignment" | "resource" | "$transaction">;
export interface RecomputeResourceValueScoresInput {
daysBack?: number;
}
export async function recomputeResourceValueScores(
db: ResourceValueScoreDbClient,
input: RecomputeResourceValueScoresInput = {},
) {
if (
typeof db.resource?.findMany !== "function" ||
typeof db.resource?.update !== "function"
) {
return { updated: 0 };
}
const daysBack = input.daysBack ?? 90;
const [resources, settings] = await Promise.all([
db.resource.findMany({
where: { isActive: true },
select: {
id: true,
skills: true,
lcrCents: true,
chargeabilityTarget: true,
},
}),
db.systemSettings?.findUnique?.({ where: { id: "singleton" } }) ?? Promise.resolve(null),
]);
if (resources.length === 0) {
return { updated: 0 };
}
const bookings = await listAssignmentBookings(db, {
startDate: new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000),
endDate: new Date(),
resourceIds: resources.map((resource) => resource.id),
});
const defaultWeights = {
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
skillBreadth: VALUE_SCORE_WEIGHTS.SKILL_BREADTH,
costEfficiency: VALUE_SCORE_WEIGHTS.COST_EFFICIENCY,
chargeability: VALUE_SCORE_WEIGHTS.CHARGEABILITY,
experience: VALUE_SCORE_WEIGHTS.EXPERIENCE,
};
const weights =
(settings?.scoreWeights as unknown as typeof defaultWeights | null) ?? defaultWeights;
const maxLcrCents = resources.reduce((max, resource) => Math.max(max, resource.lcrCents), 0);
const now = new Date();
type SkillRow = {
category?: string;
isMainSkill?: boolean;
proficiency: number;
skill: string;
yearsExperience?: number;
};
const totalWorkDays = daysBack * (5 / 7);
const availableHours = totalWorkDays * 8;
const updates = resources.map((resource) => {
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const bookedHours = resourceBookings.reduce((sum, booking) => {
const days = Math.max(
0,
(new Date(booking.endDate).getTime() - new Date(booking.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1,
);
return sum + booking.hoursPerDay * days;
}, 0);
const currentChargeability =
availableHours > 0 ? Math.min(100, (bookedHours / availableHours) * 100) : 0;
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
const breakdown = computeValueScore(
{
skills: skills as unknown as import("@planarchy/shared").SkillEntry[],
lcrCents: resource.lcrCents,
chargeabilityTarget: resource.chargeabilityTarget,
currentChargeability,
maxLcrCents,
},
weights,
);
return db.resource.update({
where: { id: resource.id },
data: {
valueScore: breakdown.total,
valueScoreBreakdown:
breakdown as unknown as import("@planarchy/db").Prisma.InputJsonValue,
valueScoreUpdatedAt: now,
},
});
});
await db.$transaction(updates);
return { updated: updates.length };
}
@@ -6,6 +6,7 @@ test("parseImportDispoBatchArgs uses the agreed workbook defaults", () => {
const options = parseImportDispoBatchArgs([]);
assert.equal(options.allowTbdUnresolved, true);
assert.equal(options.importTbdProjects, false);
assert.equal(options.skipCommit, false);
assert.equal(options.strictSourceData, false);
assert.equal(options.previewUnresolvedLimit, 10);
@@ -33,6 +34,7 @@ test("parseImportDispoBatchArgs applies operator overrides", () => {
"--skip-commit",
"--strict-source-data",
"--disallow-tbd",
"--import-tbd-projects",
"--no-roster",
"--no-cost",
]);
@@ -43,6 +45,7 @@ test("parseImportDispoBatchArgs applies operator overrides", () => {
assert.equal(options.skipCommit, true);
assert.equal(options.strictSourceData, true);
assert.equal(options.allowTbdUnresolved, false);
assert.equal(options.importTbdProjects, true);
assert.equal(options.rosterWorkbookPath, undefined);
assert.equal(options.costWorkbookPath, undefined);
});
+10
View File
@@ -37,6 +37,7 @@ export interface ImportDispoBatchOptions {
allowTbdUnresolved: boolean;
chargeabilityWorkbookPath: string;
costWorkbookPath: string | undefined;
importTbdProjects: boolean;
notes: string | undefined;
planningWorkbookPath: string;
previewUnresolvedLimit: number;
@@ -93,6 +94,7 @@ interface StageDispoImportBatchResult {
interface CommitDispoImportBatchInput {
allowTbdUnresolved?: boolean;
importTbdProjects?: boolean;
importBatchId: string;
}
@@ -149,6 +151,7 @@ export function parseImportDispoBatchArgs(argv: string[]): ImportDispoBatchOptio
allowTbdUnresolved: true,
chargeabilityWorkbookPath: DEFAULT_CHARGEABILITY_WORKBOOK,
costWorkbookPath: DEFAULT_COST_WORKBOOK,
importTbdProjects: false,
notes: undefined,
planningWorkbookPath: DEFAULT_PLANNING_WORKBOOK,
previewUnresolvedLimit: 10,
@@ -222,6 +225,11 @@ export function parseImportDispoBatchArgs(argv: string[]): ImportDispoBatchOptio
continue;
}
if (argument === "--import-tbd-projects") {
options.importTbdProjects = true;
continue;
}
if (argument === "--no-roster") {
options.rosterWorkbookPath = undefined;
continue;
@@ -259,6 +267,7 @@ function buildHelpText() {
" --skip-commit Stage and assess readiness only",
" --strict-source-data Require readiness without fallback assumptions",
" --disallow-tbd Fail commit if [tbd] unresolved rows remain",
" --import-tbd-projects Commit [tbd] rows as provisional DRAFT projects",
].join("\n");
}
@@ -415,6 +424,7 @@ export async function runImportDispoBatch(options: ImportDispoBatchOptions) {
const commitResult = await dispoImport.commitDispoImportBatch(prisma, {
allowTbdUnresolved: options.allowTbdUnresolved,
importTbdProjects: options.importTbdProjects,
importBatchId: stageResult.batchId,
});
@@ -0,0 +1,226 @@
import { describe, expect, it } from "vitest";
import {
computeCommercialTermsSummary,
computeMilestoneAmounts,
defaultCommercialTerms,
validatePaymentMilestones,
} from "../estimate/commercial-terms.js";
import type { CommercialTerms, PaymentMilestone } from "@planarchy/shared";
const BASE_TERMS: CommercialTerms = {
pricingModel: "fixed_price",
contingencyPercent: 0,
discountPercent: 0,
paymentTermDays: 30,
paymentMilestones: [],
warrantyMonths: 0,
};
describe("computeCommercialTermsSummary", () => {
it("returns unchanged totals when contingency and discount are 0", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 100_000_00,
basePriceCents: 150_000_00,
terms: BASE_TERMS,
});
expect(result.adjustedCostCents).toBe(100_000_00);
expect(result.adjustedPriceCents).toBe(150_000_00);
expect(result.contingencyCents).toBe(0);
expect(result.discountCents).toBe(0);
expect(result.adjustedMarginCents).toBe(50_000_00);
});
it("adds contingency to cost", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 100_000_00,
basePriceCents: 150_000_00,
terms: { ...BASE_TERMS, contingencyPercent: 10 },
});
expect(result.contingencyCents).toBe(10_000_00);
expect(result.adjustedCostCents).toBe(110_000_00);
expect(result.adjustedPriceCents).toBe(150_000_00);
expect(result.adjustedMarginCents).toBe(40_000_00);
});
it("subtracts discount from price", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 100_000_00,
basePriceCents: 200_000_00,
terms: { ...BASE_TERMS, discountPercent: 5 },
});
expect(result.discountCents).toBe(10_000_00);
expect(result.adjustedPriceCents).toBe(190_000_00);
expect(result.adjustedCostCents).toBe(100_000_00);
expect(result.adjustedMarginCents).toBe(90_000_00);
});
it("applies both contingency and discount together", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 100_000_00,
basePriceCents: 200_000_00,
terms: { ...BASE_TERMS, contingencyPercent: 15, discountPercent: 10 },
});
// cost: 100k + 15% = 115k
expect(result.adjustedCostCents).toBe(115_000_00);
// price: 200k - 10% = 180k
expect(result.adjustedPriceCents).toBe(180_000_00);
// margin: 180k - 115k = 65k
expect(result.adjustedMarginCents).toBe(65_000_00);
});
it("computes margin percent correctly", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 70_000_00,
basePriceCents: 100_000_00,
terms: BASE_TERMS,
});
expect(result.adjustedMarginPercent).toBeCloseTo(30, 1);
});
it("handles zero price gracefully (margin% = 0)", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 50_000_00,
basePriceCents: 0,
terms: BASE_TERMS,
});
expect(result.adjustedMarginPercent).toBe(0);
expect(result.adjustedMarginCents).toBe(-50_000_00);
});
it("handles negative margin when discount exceeds buffer", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 100_000_00,
basePriceCents: 110_000_00,
terms: { ...BASE_TERMS, contingencyPercent: 20, discountPercent: 10 },
});
// cost: 100k + 20% = 120k, price: 110k - 10% = 99k → margin = -21k
expect(result.adjustedMarginCents).toBe(-21_000_00);
expect(result.adjustedMarginPercent).toBeLessThan(0);
});
it("rounds contingency and discount to integer cents", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 33_333,
basePriceCents: 66_667,
terms: { ...BASE_TERMS, contingencyPercent: 7.5, discountPercent: 3.3 },
});
expect(Number.isInteger(result.contingencyCents)).toBe(true);
expect(Number.isInteger(result.discountCents)).toBe(true);
expect(Number.isInteger(result.adjustedCostCents)).toBe(true);
expect(Number.isInteger(result.adjustedPriceCents)).toBe(true);
});
});
describe("validatePaymentMilestones", () => {
it("returns no warnings for empty milestones", () => {
expect(validatePaymentMilestones([])).toEqual([]);
});
it("returns no warnings when milestones sum to 100%", () => {
const milestones: PaymentMilestone[] = [
{ label: "Kickoff", percent: 30 },
{ label: "Midpoint", percent: 40 },
{ label: "Delivery", percent: 30 },
];
expect(validatePaymentMilestones(milestones)).toEqual([]);
});
it("warns when milestones do not sum to 100%", () => {
const milestones: PaymentMilestone[] = [
{ label: "Kickoff", percent: 30 },
{ label: "Delivery", percent: 50 },
];
const warnings = validatePaymentMilestones(milestones);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("80.0%");
});
it("warns about zero-percent milestones", () => {
const milestones: PaymentMilestone[] = [
{ label: "Kickoff", percent: 0 },
{ label: "Delivery", percent: 100 },
];
const warnings = validatePaymentMilestones(milestones);
expect(warnings.some((w) => w.includes("0%"))).toBe(true);
});
it("warns about empty labels", () => {
const milestones: PaymentMilestone[] = [
{ label: " ", percent: 50 },
{ label: "End", percent: 50 },
];
const warnings = validatePaymentMilestones(milestones);
expect(warnings.some((w) => w.includes("empty label"))).toBe(true);
});
it("warns about non-chronological dates", () => {
const milestones: PaymentMilestone[] = [
{ label: "Late", percent: 50, dueDate: "2026-06-01" },
{ label: "Early", percent: 50, dueDate: "2026-03-01" },
];
const warnings = validatePaymentMilestones(milestones);
expect(warnings.some((w) => w.includes("earlier date"))).toBe(true);
});
it("ignores date order for milestones without dates", () => {
const milestones: PaymentMilestone[] = [
{ label: "A", percent: 50 },
{ label: "B", percent: 50, dueDate: "2026-01-01" },
];
expect(validatePaymentMilestones(milestones)).toEqual([]);
});
});
describe("computeMilestoneAmounts", () => {
it("computes amounts from adjusted price", () => {
const milestones: PaymentMilestone[] = [
{ label: "Kickoff", percent: 30 },
{ label: "Final", percent: 70 },
];
const amounts = computeMilestoneAmounts(100_000_00, milestones);
expect(amounts).toHaveLength(2);
expect(amounts[0]!.amountCents).toBe(30_000_00);
expect(amounts[1]!.amountCents).toBe(70_000_00);
});
it("rounds amounts to integer cents", () => {
const milestones: PaymentMilestone[] = [
{ label: "A", percent: 33.33 },
{ label: "B", percent: 33.33 },
{ label: "C", percent: 33.34 },
];
const amounts = computeMilestoneAmounts(99_999, milestones);
for (const a of amounts) {
expect(Number.isInteger(a.amountCents)).toBe(true);
}
});
it("preserves due dates", () => {
const milestones: PaymentMilestone[] = [
{ label: "M1", percent: 100, dueDate: "2026-06-15" },
];
const amounts = computeMilestoneAmounts(50_000_00, milestones);
expect(amounts[0]!.dueDate).toBe("2026-06-15");
});
});
describe("defaultCommercialTerms", () => {
it("returns valid defaults", () => {
const terms = defaultCommercialTerms();
expect(terms.pricingModel).toBe("fixed_price");
expect(terms.contingencyPercent).toBe(0);
expect(terms.discountPercent).toBe(0);
expect(terms.paymentTermDays).toBe(30);
expect(terms.paymentMilestones).toEqual([]);
expect(terms.warrantyMonths).toBe(0);
});
});
@@ -0,0 +1,129 @@
/**
* Pure commercial-terms calculation engine.
*
* Applies contingency, discount, and payment milestone validation
* to base cost/price totals from demand lines.
*/
import type { CommercialTerms, CommercialTermsSummary, PaymentMilestone } from "@planarchy/shared";
export interface CommercialTermsInput {
baseCostCents: number;
basePriceCents: number;
terms: CommercialTerms;
}
/**
* Compute adjusted totals after applying contingency and discount.
*
* Contingency is added to cost (risk buffer on cost side).
* Discount is subtracted from price (reduction on sell side).
*
* adjustedCost = baseCost * (1 + contingency%)
* adjustedPrice = basePrice * (1 - discount%)
* margin = adjustedPrice - adjustedCost
*/
export function computeCommercialTermsSummary(
input: CommercialTermsInput,
): CommercialTermsSummary {
const { baseCostCents, basePriceCents, terms } = input;
const contingencyFactor = terms.contingencyPercent / 100;
const discountFactor = terms.discountPercent / 100;
const contingencyCents = Math.round(baseCostCents * contingencyFactor);
const discountCents = Math.round(basePriceCents * discountFactor);
const adjustedCostCents = baseCostCents + contingencyCents;
const adjustedPriceCents = basePriceCents - discountCents;
const adjustedMarginCents = adjustedPriceCents - adjustedCostCents;
const adjustedMarginPercent =
adjustedPriceCents > 0
? (adjustedMarginCents / adjustedPriceCents) * 100
: 0;
return {
baseCostCents,
basePriceCents,
contingencyCents,
discountCents,
adjustedCostCents,
adjustedPriceCents,
adjustedMarginCents,
adjustedMarginPercent,
};
}
/**
* Validate that payment milestones sum to 100%.
* Returns list of validation warnings (empty = valid).
*/
export function validatePaymentMilestones(
milestones: PaymentMilestone[],
): string[] {
const warnings: string[] = [];
if (milestones.length === 0) return warnings;
const totalPercent = milestones.reduce((sum, m) => sum + m.percent, 0);
if (Math.abs(totalPercent - 100) > 0.01) {
warnings.push(
`Payment milestones sum to ${totalPercent.toFixed(1)}%, expected 100%`,
);
}
for (let i = 0; i < milestones.length; i++) {
const m = milestones[i]!;
if (m.percent <= 0) {
warnings.push(`Milestone "${m.label}" has 0% or negative allocation`);
}
if (!m.label.trim()) {
warnings.push(`Milestone at position ${i + 1} has empty label`);
}
}
// Check chronological order when dates are provided
const datedMilestones = milestones.filter(
(m): m is PaymentMilestone & { dueDate: string } =>
m.dueDate != null && m.dueDate !== "",
);
for (let i = 1; i < datedMilestones.length; i++) {
if (datedMilestones[i]!.dueDate < datedMilestones[i - 1]!.dueDate) {
warnings.push(
`Milestone "${datedMilestones[i]!.label}" has an earlier date than "${datedMilestones[i - 1]!.label}"`,
);
}
}
return warnings;
}
/**
* Compute per-milestone payment amounts from adjusted price.
*/
export function computeMilestoneAmounts(
adjustedPriceCents: number,
milestones: PaymentMilestone[],
): Array<{ label: string; percent: number; amountCents: number; dueDate?: string | null }> {
return milestones.map((m) => ({
label: m.label,
percent: m.percent,
amountCents: Math.round(adjustedPriceCents * (m.percent / 100)),
...(m.dueDate !== undefined ? { dueDate: m.dueDate } : {}),
}));
}
/**
* Default commercial terms for a new estimate.
*/
export function defaultCommercialTerms(): CommercialTerms {
return {
pricingModel: "fixed_price",
contingencyPercent: 0,
discountPercent: 0,
paymentTermDays: 30,
paymentMilestones: [],
warrantyMonths: 0,
};
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./commercial-terms.js";
export * from "./effort-rules.js";
export * from "./experience-multiplier.js";
export * from "./export-serializer.js";
+2
View File
@@ -8,6 +8,8 @@ export const RESOURCE_COLUMNS: ColumnDef[] = [
{ key: "chargeability", label: "Chargeability", defaultVisible: true, hideable: true, sortable: true },
{ key: "lcr", label: "LCR", defaultVisible: false, hideable: true },
{ key: "valueScore", label: "Score", defaultVisible: false, hideable: true },
{ key: "rolledOff", label: "Rolled Off", defaultVisible: false, hideable: true },
{ key: "departed", label: "Departed", defaultVisible: false, hideable: true },
{ key: "isActive", label: "Status", defaultVisible: true, hideable: true },
];
@@ -334,6 +334,36 @@ export const UpdateWeeklyPhasingSchema = z.object({
export type GenerateWeeklyPhasingInput = z.infer<typeof GenerateWeeklyPhasingSchema>;
export type UpdateWeeklyPhasingInput = z.infer<typeof UpdateWeeklyPhasingSchema>;
// ─── Commercial Terms ───────────────────────────────────────────────────────
export const PricingModelSchema = z.enum(["fixed_price", "time_and_materials", "hybrid"]);
export const PaymentMilestoneSchema = z.object({
label: z.string().min(1).max(200),
percent: z.number().min(0).max(100),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
description: z.string().max(1000).nullable().optional(),
});
export const CommercialTermsSchema = z.object({
pricingModel: PricingModelSchema.default("fixed_price"),
contingencyPercent: z.number().min(0).max(100).default(0),
discountPercent: z.number().min(0).max(100).default(0),
paymentTermDays: z.number().int().min(0).max(365).default(30),
paymentMilestones: z.array(PaymentMilestoneSchema).default([]),
warrantyMonths: z.number().int().min(0).max(60).default(0),
notes: z.string().max(5_000).nullable().optional(),
});
export const UpdateCommercialTermsSchema = z.object({
estimateId: z.string(),
versionId: z.string().optional(),
terms: CommercialTermsSchema,
});
export type CommercialTermsInput = z.infer<typeof CommercialTermsSchema>;
export type UpdateCommercialTermsInput = z.infer<typeof UpdateCommercialTermsSchema>;
export type CreateEstimateInput = z.infer<typeof CreateEstimateSchema>;
export type UpdateEstimateInput = z.infer<typeof UpdateEstimateSchema>;
export type UpdateEstimateDraftInput = z.infer<typeof UpdateEstimateDraftSchema>;
+32
View File
@@ -326,6 +326,38 @@ export interface WeeklyPhasingConfig {
pattern: PhasingPattern;
}
// --- Commercial Terms ---
export type PricingModel = "fixed_price" | "time_and_materials" | "hybrid";
export interface PaymentMilestone {
label: string;
percent: number;
dueDate?: string | null;
description?: string | null;
}
export interface CommercialTerms {
pricingModel: PricingModel;
contingencyPercent: number;
discountPercent: number;
paymentTermDays: number;
paymentMilestones: PaymentMilestone[];
warrantyMonths: number;
notes?: string | null;
}
export interface CommercialTermsSummary {
baseCostCents: number;
basePriceCents: number;
contingencyCents: number;
discountCents: number;
adjustedCostCents: number;
adjustedPriceCents: number;
adjustedMarginCents: number;
adjustedMarginPercent: number;
}
export interface EstimatePlanningHandoffResult {
estimateId: string;
estimateVersionId: string;
+1
View File
@@ -52,4 +52,5 @@ export interface Resource {
valueScore?: number | null;
valueScoreBreakdown?: ValueScoreBreakdown | null;
valueScoreUpdatedAt?: Date | null;
isOwnedByCurrentUser?: boolean;
}
+3
View File
@@ -193,6 +193,9 @@ importers:
'@planarchy/shared':
specifier: workspace:*
version: link:../shared
'@planarchy/staffing':
specifier: workspace:*
version: link:../staffing
'@trpc/server':
specifier: ^11.0.0
version: 11.11.0(typescript@5.9.3)