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:
@@ -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,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,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 />;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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> = >85% · <span className="text-red-300">Red</span> = >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> = >85% ·{" "}
|
||||
<span className="text-red-300">Red</span> = >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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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">×</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-xl leading-none text-gray-400 transition hover:text-gray-600 dark:hover:text-gray-200"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
|
||||
@@ -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">×</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActionError(null)}
|
||||
className="text-red-400 hover:text-red-600 text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
|
||||
@@ -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 & 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 */}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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-/),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from "./commercial-terms.js";
|
||||
export * from "./effort-rules.js";
|
||||
export * from "./experience-multiplier.js";
|
||||
export * from "./export-serializer.js";
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -52,4 +52,5 @@ export interface Resource {
|
||||
valueScore?: number | null;
|
||||
valueScoreBreakdown?: ValueScoreBreakdown | null;
|
||||
valueScoreUpdatedAt?: Date | null;
|
||||
isOwnedByCurrentUser?: boolean;
|
||||
}
|
||||
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user