chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,243 @@
"use client";
import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
import type { SkillEntry } from "@planarchy/shared";
interface ParsedEntry {
fileName: string;
candidateEid: string; // guessed from filename (no extension, lowercased)
selectedEid: string;
skills: SkillEntry[];
employeeInfo: Record<string, string>;
matchedRoleName: string | null;
status: "pending" | "matched" | "unmatched";
}
export function BatchSkillImport() {
const [entries, setEntries] = useState<ParsedEntry[]>([]);
const [result, setResult] = useState<{ updated: number; notFound: number } | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const { data: roles } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const batchMutation = trpc.resource.batchImportSkillMatrices.useMutation({
onSuccess: (data) => { setResult(data); setSubmitting(false); },
onError: (err) => { setError(err.message); setSubmitting(false); },
});
async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
setResult(null);
setError(null);
const roleNames = (roles ?? []).map((r) => r.name);
const resourceList = (resources?.resources ?? []) as SimpleResource[];
const parsed: ParsedEntry[] = await Promise.all(
files.map(async (file) => {
const baseName = file.name.replace(/\.[^.]+$/, "");
// Guess EID: try matching against resource displayName or eid
const candidateEid = baseName.toLowerCase().replace(/\s+/g, ".");
const matchedResource = resourceList.find(
(r) =>
r.eid.toLowerCase() === candidateEid ||
r.displayName.toLowerCase().replace(/\s+/g, ".") === candidateEid ||
r.displayName.toLowerCase() === baseName.toLowerCase(),
);
try {
const buffer = await file.arrayBuffer();
const result = parseSkillMatrixWorkbook(buffer);
let roleId: string | undefined;
let matchedRoleName: string | undefined;
if (result.employeeInfo.areaOfExpertise) {
const matched = matchRoleName(result.employeeInfo.areaOfExpertise, roleNames);
if (matched) {
const role = (roles ?? []).find((r) => r.name === matched);
roleId = role?.id;
matchedRoleName = matched;
}
}
const empInfo: Record<string, string> = {};
if (roleId) empInfo["roleId"] = roleId;
if (result.employeeInfo.portfolioUrl) empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
return {
fileName: file.name,
candidateEid,
selectedEid: matchedResource?.eid ?? candidateEid,
skills: result.skills,
employeeInfo: empInfo,
matchedRoleName: matchedRoleName ?? null,
status: matchedResource ? "matched" : "unmatched",
} satisfies ParsedEntry;
} catch {
return {
fileName: file.name,
candidateEid,
selectedEid: matchedResource?.eid ?? "",
skills: [],
employeeInfo: {},
matchedRoleName: null,
status: "unmatched",
} satisfies ParsedEntry;
}
}),
);
setEntries(parsed);
}
function updateEid(idx: number, eid: string) {
setEntries((prev) =>
prev.map((e, i) =>
i === idx
? {
...e,
selectedEid: eid,
status: eid ? "matched" : "unmatched",
}
: e,
),
);
}
function handleImport() {
const toImport = entries.filter((e) => e.selectedEid && e.skills.length > 0);
if (toImport.length === 0) return;
setSubmitting(true);
batchMutation.mutate({
entries: toImport.map((e) => ({
eid: e.selectedEid,
skills: e.skills,
employeeInfo: {
...(e.employeeInfo["roleId"] ? { roleId: e.employeeInfo["roleId"] } : {}),
...(e.employeeInfo["portfolioUrl"] ? { portfolioUrl: e.employeeInfo["portfolioUrl"] } : {}),
},
})),
});
}
type SimpleResource = { eid: string; displayName: string };
const resourceList = (resources?.resources ?? []) as SimpleResource[];
const matched = entries.filter((e) => e.status === "matched").length;
const unmatched = entries.filter((e) => e.status === "unmatched").length;
return (
<div className="p-6 max-w-4xl">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Batch Skill Matrix Import</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Upload multiple skill matrix files at once. Files are matched to resources by filename.
</p>
</div>
{/* Upload area */}
<div
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors mb-6 bg-white dark:bg-gray-800"
onClick={() => fileRef.current?.click()}
>
<svg className="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Click to select multiple .xlsx files</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Name files after resource EID or display name for automatic matching</p>
<input ref={fileRef} type="file" accept=".xlsx,.xls" multiple className="hidden" onChange={handleFiles} />
</div>
{/* Summary */}
{entries.length > 0 && (
<div className="flex gap-4 mb-4">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-lg px-4 py-2 text-sm">
<span className="font-semibold text-green-700 dark:text-green-400">{matched}</span>
<span className="text-green-600 dark:text-green-400 ml-1">matched</span>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg px-4 py-2 text-sm">
<span className="font-semibold text-yellow-700 dark:text-yellow-400">{unmatched}</span>
<span className="text-yellow-600 dark:text-yellow-400 ml-1">unmatched (select EID manually)</span>
</div>
</div>
)}
{/* Entries table */}
{entries.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">File</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Resource EID</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Skills</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Role Match</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{entries.map((entry, idx) => (
<tr key={idx} className={entry.status === "unmatched" ? "bg-yellow-50 dark:bg-yellow-900/10" : ""}>
<td className="px-4 py-3 text-xs text-gray-600 dark:text-gray-400 font-mono">{entry.fileName}</td>
<td className="px-4 py-3">
{entry.status === "matched" ? (
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">{entry.selectedEid}</span>
) : (
<select
className="w-full px-2 py-1.5 border border-yellow-300 dark:border-yellow-600 rounded text-sm bg-white dark:bg-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-500"
value={entry.selectedEid}
onChange={(e) => updateEid(idx, e.target.value)}
>
<option value=""> Select resource </option>
{resourceList.map((r) => (
<option key={r.eid} value={r.eid}>{r.displayName} ({r.eid})</option>
))}
</select>
)}
</td>
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{entry.skills.length}</td>
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{entry.matchedRoleName ?? "—"}</td>
<td className="px-4 py-3 text-center">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
entry.status === "matched" ? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400" : "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400"
}`}>
{entry.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">{error}</div>
)}
{result && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Import complete: <strong>{result.updated}</strong> updated, <strong>{result.notFound}</strong> not found.
</div>
)}
{entries.length > 0 && !result && (
<button
type="button"
onClick={handleImport}
disabled={submitting || matched === 0}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{submitting ? "Importing…" : `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
</button>
)}
</div>
);
}
@@ -0,0 +1,290 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type ClientRow = {
id: string;
name: string;
code: string | null;
parentId: string | null;
sortOrder: number;
isActive: boolean;
};
type ClientNode = ClientRow & { children: ClientNode[] };
type EditingClient = {
id?: string;
name: string;
code: string;
parentId: string;
sortOrder: number;
};
function ClientTreeNode({
node,
onEdit,
onAddChild,
depth = 0,
}: {
node: ClientNode;
onEdit: (c: ClientRow) => void;
onAddChild: (parentId: string) => void;
depth?: number;
}) {
const [expanded, setExpanded] = useState(depth < 1);
const hasChildren = node.children.length > 0;
return (
<div>
<div
className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-800/30 rounded-lg group"
style={{ paddingLeft: `${depth * 24 + 12}px` }}
>
{hasChildren ? (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 text-xs"
>
{expanded ? "▼" : "▶"}
</button>
) : (
<span className="w-5" />
)}
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 flex-1">
{node.name}
{node.code && <span className="text-gray-400 font-mono ml-1 text-xs">[{node.code}]</span>}
</span>
{!node.isActive && <span className="text-xs text-gray-400 italic">inactive</span>}
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => onAddChild(node.id)}
className="text-xs text-green-600 hover:text-green-800 font-medium"
>
+ Child
</button>
<button
type="button"
onClick={() => onEdit(node)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
</div>
</div>
{expanded && node.children.map((child) => (
<ClientTreeNode key={child.id} node={child} onEdit={onEdit} onAddChild={onAddChild} depth={depth + 1} />
))}
</div>
);
}
export function ClientsAdminClient() {
const [editing, setEditing] = useState<EditingClient | null>(null);
const [search, setSearch] = useState("");
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: tree, isLoading } = trpc.clientEntity.getTree.useQuery();
const { data: flatList } = trpc.clientEntity.list.useQuery();
const createMut = trpc.clientEntity.create.useMutation({
onSuccess: () => { void utils.clientEntity.getTree.invalidate(); void utils.clientEntity.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const updateMut = trpc.clientEntity.update.useMutation({
onSuccess: () => { void utils.clientEntity.getTree.invalidate(); void utils.clientEntity.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const allClients = (flatList ?? []) as unknown as ClientRow[];
function openCreate(parentId?: string) {
setEditing({ name: "", code: "", parentId: parentId ?? "", sortOrder: 0 });
setError(null);
}
function openEdit(c: ClientRow) {
setEditing({
id: c.id,
name: c.name,
code: c.code ?? "",
parentId: c.parentId ?? "",
sortOrder: c.sortOrder,
});
setError(null);
}
function handleSave() {
if (!editing) return;
setError(null);
if (editing.id) {
updateMut.mutate({
id: editing.id,
data: {
name: editing.name,
code: editing.code || undefined,
parentId: editing.parentId || undefined,
sortOrder: editing.sortOrder,
},
});
} else {
createMut.mutate({
name: editing.name,
code: editing.code || undefined,
parentId: editing.parentId || undefined,
sortOrder: editing.sortOrder,
});
}
}
const isPending = createMut.isPending || updateMut.isPending;
const treeNodes = (tree ?? []) as unknown as ClientNode[];
// Simple client-side filter on tree
function filterTree(nodes: ClientNode[], q: string): ClientNode[] {
if (!q) return nodes;
const lower = q.toLowerCase();
return nodes.reduce<ClientNode[]>((acc, node) => {
const filteredChildren = filterTree(node.children, q);
if (node.name.toLowerCase().includes(lower) || (node.code ?? "").toLowerCase().includes(lower) || filteredChildren.length > 0) {
acc.push({ ...node, children: filteredChildren });
}
return acc;
}, []);
}
const filteredTree = filterTree(treeNodes, search);
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Clients</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Client hierarchy for project assignment and chargeability reporting
</p>
</div>
<button
type="button"
onClick={() => openCreate()}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
+ Add Client
</button>
</div>
<div className="mb-4">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search clients..."
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64 bg-white dark:bg-gray-900 dark:text-gray-100"
/>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{error}
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
</div>
)}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-2">
{isLoading && <div className="text-center py-8 text-gray-400">Loading...</div>}
{!isLoading && filteredTree.length === 0 && (
<div className="text-center py-8 text-gray-400">
{search ? "No clients match your search." : "No clients yet."}
</div>
)}
{filteredTree.map((node) => (
<ClientTreeNode key={node.id} node={node} onEdit={openEdit} onAddChild={(pid) => openCreate(pid)} />
))}
</div>
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Client" : "Add Client"}
</h2>
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
type="text"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
placeholder="e.g. BMW Group"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code</label>
<input
type="text"
value={editing.code}
onChange={(e) => setEditing({ ...editing, code: e.target.value })}
placeholder="BMW"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
<input
type="number"
value={editing.sortOrder}
onChange={(e) => setEditing({ ...editing, sortOrder: parseInt(e.target.value) || 0 })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Client</label>
<select
value={editing.parentId}
onChange={(e) => setEditing({ ...editing, parentId: e.target.value })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
>
<option value=""> Top level (no parent) </option>
{allClients
.filter((c) => c.id !== editing.id && c.isActive)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name} {c.code ? `[${c.code}]` : ""}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditing(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Cancel</button>
<button
type="button"
onClick={handleSave}
disabled={isPending || !editing.name}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,413 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type CountryRow = {
id: string;
code: string;
name: string;
dailyWorkingHours: number;
scheduleRules: unknown;
isActive: boolean;
metroCities: { id: string; name: string }[];
};
type EditingCountry = {
id?: string;
code: string;
name: string;
dailyWorkingHours: number;
hasSpainRules: boolean;
fridayHours: number;
summerFrom: string;
summerTo: string;
summerHours: number;
regularHours: number;
};
const emptyCountry: EditingCountry = {
code: "",
name: "",
dailyWorkingHours: 8,
hasSpainRules: false,
fridayHours: 6.5,
summerFrom: "07-01",
summerTo: "09-15",
summerHours: 6.5,
regularHours: 9,
};
function parseSpainRules(rules: unknown): Partial<EditingCountry> {
if (!rules || typeof rules !== "object") return { hasSpainRules: false };
const r = rules as Record<string, unknown>;
if (r.type !== "spain") return { hasSpainRules: false };
const sp = r as { fridayHours?: number; summerPeriod?: { from?: string; to?: string }; summerHours?: number; regularHours?: number };
return {
hasSpainRules: true,
fridayHours: sp.fridayHours ?? 6.5,
summerFrom: sp.summerPeriod?.from ?? "07-01",
summerTo: sp.summerPeriod?.to ?? "09-15",
summerHours: sp.summerHours ?? 6.5,
regularHours: sp.regularHours ?? 9,
};
}
export function CountriesClient() {
const [editing, setEditing] = useState<EditingCountry | null>(null);
const [cityName, setCityName] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: countries, isLoading } = trpc.country.list.useQuery();
// @ts-ignore TS2589: tRPC infers union type too deeply for nullable JSONB scheduleRules schema
const createMut = trpc.country.create.useMutation({
onSuccess: () => { void utils.country.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const updateMut = trpc.country.update.useMutation({
onSuccess: () => { void utils.country.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const createCityMut = trpc.country.createCity.useMutation({
onSuccess: () => { void utils.country.list.invalidate(); setCityName(""); },
onError: (e) => setError(e.message),
});
const deleteCityMut = trpc.country.deleteCity.useMutation({
onSuccess: () => void utils.country.list.invalidate(),
onError: (e) => setError(e.message),
});
function openCreate() {
setEditing({ ...emptyCountry });
setError(null);
}
function openEdit(c: CountryRow) {
const spainParts = parseSpainRules(c.scheduleRules);
setEditing({
id: c.id,
code: c.code,
name: c.name,
dailyWorkingHours: c.dailyWorkingHours,
hasSpainRules: false,
fridayHours: 6.5,
summerFrom: "07-01",
summerTo: "09-15",
summerHours: 6.5,
regularHours: 9,
...spainParts,
});
setError(null);
}
function handleSave() {
if (!editing) return;
setError(null);
const scheduleRules = editing.hasSpainRules
? {
type: "spain" as const,
fridayHours: editing.fridayHours,
summerPeriod: { from: editing.summerFrom, to: editing.summerTo },
summerHours: editing.summerHours,
regularHours: editing.regularHours,
}
: null;
if (editing.id) {
updateMut.mutate({
id: editing.id,
data: {
code: editing.code,
name: editing.name,
dailyWorkingHours: editing.dailyWorkingHours,
scheduleRules,
},
});
} else {
createMut.mutate({
code: editing.code,
name: editing.name,
dailyWorkingHours: editing.dailyWorkingHours,
scheduleRules,
});
}
}
function handleAddCity(countryId: string) {
if (!cityName.trim()) return;
createCityMut.mutate({ countryId, name: cityName.trim() });
}
const isPending = createMut.isPending || updateMut.isPending;
const rows = (countries ?? []) as unknown as CountryRow[];
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Countries</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage countries, daily working hours, and metro cities
</p>
</div>
<button
type="button"
onClick={openCreate}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
+ Add Country
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{error}
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
</div>
)}
{/* Country List */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Code</th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Name</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Daily Hours</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Schedule</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Cities</th>
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr><td colSpan={6} className="text-center py-8 text-gray-400">Loading...</td></tr>
)}
{!isLoading && rows.length === 0 && (
<tr><td colSpan={6} className="text-center py-8 text-gray-400">No countries yet.</td></tr>
)}
{rows.map((c) => (
<tr key={c.id} className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-4 py-3 font-mono font-medium text-gray-900 dark:text-gray-100">{c.code}</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{c.name}</td>
<td className="px-4 py-3 text-center text-gray-600 dark:text-gray-400">{c.dailyWorkingHours}h</td>
<td className="px-4 py-3 text-center">
{c.scheduleRules && typeof c.scheduleRules === "object" && (c.scheduleRules as Record<string, unknown>).type === "spain" ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">Spain</span>
) : (
<span className="text-gray-400 text-xs">Standard</span>
)}
</td>
<td className="px-4 py-3 text-center">
<button
type="button"
onClick={() => setExpandedId(expandedId === c.id ? null : c.id)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
{c.metroCities.length} cities {expandedId === c.id ? "▲" : "▼"}
</button>
</td>
<td className="px-4 py-3 text-right">
<button type="button" onClick={() => openEdit(c)} className="text-xs text-brand-600 hover:text-brand-800 font-medium">Edit</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Expanded Metro Cities */}
{expandedId && (() => {
const country = rows.find((c) => c.id === expandedId);
if (!country) return null;
return (
<div className="mt-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Metro Cities for {country.name}
</h3>
<div className="flex flex-wrap gap-2 mb-3">
{country.metroCities.map((city) => (
<span key={city.id} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300">
{city.name}
<button
type="button"
onClick={() => {
if (confirm(`Delete metro city "${city.name}"?`)) {
deleteCityMut.mutate({ id: city.id });
}
}}
className="text-gray-400 hover:text-red-500 text-xs leading-none ml-1"
>
&times;
</button>
</span>
))}
{country.metroCities.length === 0 && (
<span className="text-sm text-gray-400">No metro cities yet</span>
)}
</div>
<div className="flex gap-2">
<input
type="text"
value={cityName}
onChange={(e) => setCityName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleAddCity(country.id); }}
placeholder="New city name..."
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 flex-1"
/>
<button
type="button"
onClick={() => handleAddCity(country.id)}
disabled={createCityMut.isPending || !cityName.trim()}
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
Add
</button>
</div>
</div>
);
})()}
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-lg mx-4 flex flex-col max-h-[90vh]">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Country" : "Add Country"}
</h2>
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code</label>
<input
type="text"
value={editing.code}
onChange={(e) => setEditing({ ...editing, code: e.target.value.toUpperCase() })}
maxLength={3}
placeholder="DE"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Daily Hours</label>
<input
type="number"
value={editing.dailyWorkingHours}
onChange={(e) => setEditing({ ...editing, dailyWorkingHours: parseFloat(e.target.value) || 8 })}
min={1}
max={24}
step={0.5}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
type="text"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
placeholder="Germany"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
{/* Spain Schedule Rules */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={editing.hasSpainRules}
onChange={(e) => setEditing({ ...editing, hasSpainRules: e.target.checked })}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Variable schedule (Spain-type)
</label>
{editing.hasSpainRules && (
<div className="mt-3 space-y-3 pl-6 border-l-2 border-amber-300 dark:border-amber-700">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Friday Hours</label>
<input
type="number"
value={editing.fridayHours}
onChange={(e) => setEditing({ ...editing, fridayHours: parseFloat(e.target.value) || 6.5 })}
step={0.5}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Regular Hours (Mon-Thu)</label>
<input
type="number"
value={editing.regularHours}
onChange={(e) => setEditing({ ...editing, regularHours: parseFloat(e.target.value) || 9 })}
step={0.5}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer From</label>
<input
type="text"
value={editing.summerFrom}
onChange={(e) => setEditing({ ...editing, summerFrom: e.target.value })}
placeholder="07-01"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer To</label>
<input
type="text"
value={editing.summerTo}
onChange={(e) => setEditing({ ...editing, summerTo: e.target.value })}
placeholder="09-15"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer Hours</label>
<input
type="number"
value={editing.summerHours}
onChange={(e) => setEditing({ ...editing, summerHours: parseFloat(e.target.value) || 6.5 })}
step={0.5}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
</div>
)}
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditing(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Cancel</button>
<button
type="button"
onClick={handleSave}
disabled={isPending || !editing.code || !editing.name}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,420 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type EffortUnitMode = "per_frame" | "per_item" | "flat";
type EditingRule = {
id?: string;
scopeType: string;
discipline: string;
chapter: string;
unitMode: EffortUnitMode;
hoursPerUnit: number;
description: string;
sortOrder: number;
};
type EditingRuleSet = {
id?: string;
name: string;
description: string;
isDefault: boolean;
rules: EditingRule[];
};
const UNIT_MODE_LABELS: Record<EffortUnitMode, string> = {
per_frame: "Per frame",
per_item: "Per item",
flat: "Flat",
};
const SCOPE_TYPE_PRESETS = ["SHOT", "ASSET", "ENVIRONMENT", "SEQUENCE", "OTHER"];
const DISCIPLINE_PRESETS = [
"3D Animation",
"3D Lighting",
"3D Modeling",
"3D Rigging",
"3D Environment",
"Compositing",
"Motion Graphics",
"Art Direction",
"Conception / R&D",
"Project Management",
"Production Supervisor",
"DataPrep",
"Audio Production",
];
const emptyRule: EditingRule = {
scopeType: "SHOT",
discipline: "",
chapter: "",
unitMode: "per_frame",
hoursPerUnit: 0,
description: "",
sortOrder: 0,
};
const emptyRuleSet: EditingRuleSet = {
name: "",
description: "",
isDefault: false,
rules: [],
};
export function EffortRulesClient() {
const utils = trpc.useUtils();
const { data: ruleSets, isLoading } = trpc.effortRule.list.useQuery();
const createMutation = trpc.effortRule.create.useMutation({
onSuccess: () => {
utils.effortRule.list.invalidate();
setEditing(null);
},
});
const updateMutation = trpc.effortRule.update.useMutation({
onSuccess: () => {
utils.effortRule.list.invalidate();
setEditing(null);
},
});
const deleteMutation = trpc.effortRule.delete.useMutation({
onSuccess: () => utils.effortRule.list.invalidate(),
});
const [editing, setEditing] = useState<EditingRuleSet | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
function handleSave() {
if (!editing) return;
const payload = {
name: editing.name,
description: editing.description || undefined,
isDefault: editing.isDefault,
rules: editing.rules.map((r, i) => ({
scopeType: r.scopeType,
discipline: r.discipline,
...(r.chapter ? { chapter: r.chapter } : {}),
unitMode: r.unitMode,
hoursPerUnit: r.hoursPerUnit,
...(r.description ? { description: r.description } : {}),
sortOrder: i,
})),
};
if (editing.id) {
updateMutation.mutate({ id: editing.id, ...payload });
} else {
createMutation.mutate(payload);
}
}
function handleEdit(ruleSet: NonNullable<typeof ruleSets>[number]) {
setEditing({
id: ruleSet.id,
name: ruleSet.name,
description: ruleSet.description ?? "",
isDefault: ruleSet.isDefault,
rules: ruleSet.rules.map((r) => ({
id: r.id,
scopeType: r.scopeType,
discipline: r.discipline,
chapter: r.chapter ?? "",
unitMode: r.unitMode as EffortUnitMode,
hoursPerUnit: r.hoursPerUnit,
description: r.description ?? "",
sortOrder: r.sortOrder,
})),
});
}
function addRule() {
if (!editing) return;
setEditing({
...editing,
rules: [...editing.rules, { ...emptyRule, sortOrder: editing.rules.length }],
});
}
function removeRule(index: number) {
if (!editing) return;
setEditing({
...editing,
rules: editing.rules.filter((_, i) => i !== index),
});
}
function updateRule(index: number, updates: Partial<EditingRule>) {
if (!editing) return;
setEditing({
...editing,
rules: editing.rules.map((r, i) => (i === index ? { ...r, ...updates } : r)),
});
}
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Effort Rules</h1>
<p className="text-sm text-gray-500">
Define rules for auto-generating demand lines from scope items.
</p>
</div>
{!editing && (
<button
onClick={() => setEditing({ ...emptyRuleSet })}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700"
>
New rule set
</button>
)}
</div>
{/* Editor */}
{editing && (
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-gray-900">
{editing.id ? "Edit rule set" : "New rule set"}
</h2>
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Name</label>
<input
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm"
placeholder="e.g. CGI Standard Rules"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Description</label>
<input
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm"
placeholder="Optional description"
/>
</div>
</div>
<label className="mb-4 flex items-center gap-2 text-sm text-gray-600">
<input
type="checkbox"
checked={editing.isDefault}
onChange={(e) => setEditing({ ...editing, isDefault: e.target.checked })}
className="rounded border-gray-300"
/>
Default rule set (auto-selected for new estimates)
</label>
{/* Rules table */}
<div className="mb-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">Rules ({editing.rules.length})</h3>
<button
onClick={addRule}
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
>
+ Add rule
</button>
</div>
{editing.rules.length === 0 ? (
<p className="rounded-xl bg-gray-50 p-4 text-center text-sm text-gray-400">
No rules yet. Add rules to define how scope items expand into demand lines.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-2 font-medium">Scope type</th>
<th className="px-2 py-2 font-medium">Discipline</th>
<th className="px-2 py-2 font-medium">Chapter</th>
<th className="px-2 py-2 font-medium">Unit mode</th>
<th className="px-2 py-2 text-right font-medium">Hours/unit</th>
<th className="pl-2 py-2 font-medium w-10"></th>
</tr>
</thead>
<tbody>
{editing.rules.map((rule, i) => (
<tr key={i} className="border-b border-gray-100">
<td className="py-2 pr-2">
<select
value={rule.scopeType}
onChange={(e) => updateRule(i, { scopeType: e.target.value })}
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
>
{SCOPE_TYPE_PRESETS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</td>
<td className="px-2 py-2">
<input
value={rule.discipline}
onChange={(e) => updateRule(i, { discipline: e.target.value })}
list="discipline-presets"
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
placeholder="Discipline"
/>
</td>
<td className="px-2 py-2">
<input
value={rule.chapter}
onChange={(e) => updateRule(i, { chapter: e.target.value })}
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
placeholder="Chapter"
/>
</td>
<td className="px-2 py-2">
<select
value={rule.unitMode}
onChange={(e) => updateRule(i, { unitMode: e.target.value as EffortUnitMode })}
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
>
{(Object.entries(UNIT_MODE_LABELS) as [EffortUnitMode, string][]).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</td>
<td className="px-2 py-2">
<input
type="number"
step="0.01"
min="0"
value={rule.hoursPerUnit}
onChange={(e) => updateRule(i, { hoursPerUnit: parseFloat(e.target.value) || 0 })}
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
/>
</td>
<td className="pl-2 py-2">
<button
onClick={() => removeRule(i)}
className="text-red-400 hover:text-red-600"
title="Remove"
>
x
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<datalist id="discipline-presets">
{DISCIPLINE_PRESETS.map((d) => (
<option key={d} value={d} />
))}
</datalist>
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={isSaving || !editing.name.trim()}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{isSaving ? "Saving..." : "Save"}
</button>
<button
onClick={() => setEditing(null)}
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
>
Cancel
</button>
</div>
{(createMutation.error || updateMutation.error) && (
<p className="mt-2 text-sm text-red-600">
{createMutation.error?.message || updateMutation.error?.message}
</p>
)}
</div>
)}
{/* List */}
{isLoading && <p className="text-center text-sm text-gray-400">Loading...</p>}
{ruleSets && ruleSets.length === 0 && !editing && (
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-8 text-center text-sm text-gray-500">
No effort rule sets yet. Create one to define how scope items expand into demand lines.
</div>
)}
{ruleSets?.map((rs) => (
<div key={rs.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-base font-semibold text-gray-900">{rs.name}</h3>
{rs.isDefault && (
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700">Default</span>
)}
<span className="text-sm text-gray-500">{rs.rules.length} rules</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setExpandedId(expandedId === rs.id ? null : rs.id)}
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
>
{expandedId === rs.id ? "Collapse" : "Expand"}
</button>
<button
onClick={() => handleEdit(rs)}
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
>
Edit
</button>
<button
onClick={() => {
if (confirm(`Delete rule set "${rs.name}"?`)) {
deleteMutation.mutate({ id: rs.id });
}
}}
className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
>
Delete
</button>
</div>
</div>
{rs.description && <p className="mt-1 text-sm text-gray-500">{rs.description}</p>}
{expandedId === rs.id && rs.rules.length > 0 && (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Scope type</th>
<th className="px-3 py-2 font-medium">Discipline</th>
<th className="px-3 py-2 font-medium">Chapter</th>
<th className="px-3 py-2 font-medium">Unit mode</th>
<th className="pl-3 py-2 text-right font-medium">Hours/unit</th>
</tr>
</thead>
<tbody>
{rs.rules.map((r) => (
<tr key={r.id} className="border-b border-gray-100">
<td className="py-1.5 pr-3 text-gray-900">{r.scopeType}</td>
<td className="px-3 py-1.5 text-gray-700">{r.discipline}</td>
<td className="px-3 py-1.5 text-gray-500">{r.chapter || "\u2014"}</td>
<td className="px-3 py-1.5 text-gray-500">{UNIT_MODE_LABELS[r.unitMode as EffortUnitMode] ?? r.unitMode}</td>
<td className="pl-3 py-1.5 text-right tabular-nums text-gray-700">{r.hoursPerUnit}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,475 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type EditingRule = {
id?: string;
chapter: string;
location: string;
level: string;
costMultiplier: number;
billMultiplier: number;
shoringRatio: string;
additionalEffortRatio: string;
description: string;
sortOrder: number;
};
type EditingSet = {
id?: string;
name: string;
description: string;
isDefault: boolean;
rules: EditingRule[];
};
const CHAPTER_PRESETS = [
"Animation",
"Compositing",
"3D Modeling",
"3D Lighting",
"3D Rigging",
"3D Environment",
"Motion Graphics",
"Art Direction",
"Project Management",
];
const LOCATION_PRESETS = [
"Germany",
"India",
"Poland",
"Romania",
"Spain",
"UK",
"USA",
"Canada",
];
const LEVEL_PRESETS = [
"Junior",
"Mid",
"Senior",
"Lead",
"Principal",
];
const emptyRule: EditingRule = {
chapter: "",
location: "",
level: "",
costMultiplier: 1.0,
billMultiplier: 1.0,
shoringRatio: "",
additionalEffortRatio: "",
description: "",
sortOrder: 0,
};
const emptySet: EditingSet = {
name: "",
description: "",
isDefault: false,
rules: [],
};
export function ExperienceMultipliersClient() {
const utils = trpc.useUtils();
const { data: sets, isLoading } = trpc.experienceMultiplier.list.useQuery();
const createMutation = trpc.experienceMultiplier.create.useMutation({
onSuccess: () => {
utils.experienceMultiplier.list.invalidate();
setEditing(null);
},
});
const updateMutation = trpc.experienceMultiplier.update.useMutation({
onSuccess: () => {
utils.experienceMultiplier.list.invalidate();
setEditing(null);
},
});
const deleteMutation = trpc.experienceMultiplier.delete.useMutation({
onSuccess: () => utils.experienceMultiplier.list.invalidate(),
});
const [editing, setEditing] = useState<EditingSet | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
function handleSave() {
if (!editing) return;
const payload = {
name: editing.name,
description: editing.description || undefined,
isDefault: editing.isDefault,
rules: editing.rules.map((r, i) => ({
...(r.chapter ? { chapter: r.chapter } : {}),
...(r.location ? { location: r.location } : {}),
...(r.level ? { level: r.level } : {}),
costMultiplier: r.costMultiplier,
billMultiplier: r.billMultiplier,
...(r.shoringRatio !== "" ? { shoringRatio: parseFloat(r.shoringRatio) } : {}),
...(r.additionalEffortRatio !== "" ? { additionalEffortRatio: parseFloat(r.additionalEffortRatio) } : {}),
...(r.description ? { description: r.description } : {}),
sortOrder: i,
})),
};
if (editing.id) {
updateMutation.mutate({ id: editing.id, ...payload });
} else {
createMutation.mutate(payload);
}
}
function handleEdit(set: NonNullable<typeof sets>[number]) {
setEditing({
id: set.id,
name: set.name,
description: set.description ?? "",
isDefault: set.isDefault,
rules: set.rules.map((r) => ({
id: r.id,
chapter: r.chapter ?? "",
location: r.location ?? "",
level: r.level ?? "",
costMultiplier: r.costMultiplier,
billMultiplier: r.billMultiplier,
shoringRatio: r.shoringRatio != null ? String(r.shoringRatio) : "",
additionalEffortRatio: r.additionalEffortRatio != null ? String(r.additionalEffortRatio) : "",
description: r.description ?? "",
sortOrder: r.sortOrder,
})),
});
}
function addRule() {
if (!editing) return;
setEditing({
...editing,
rules: [...editing.rules, { ...emptyRule, sortOrder: editing.rules.length }],
});
}
function removeRule(index: number) {
if (!editing) return;
setEditing({
...editing,
rules: editing.rules.filter((_, i) => i !== index),
});
}
function updateRule(index: number, updates: Partial<EditingRule>) {
if (!editing) return;
setEditing({
...editing,
rules: editing.rules.map((r, i) => (i === index ? { ...r, ...updates } : r)),
});
}
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<div className="mx-auto max-w-6xl space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Experience Multipliers</h1>
<p className="text-sm text-gray-500">
Define rate and effort adjustments by chapter, location, and experience level.
</p>
</div>
{!editing && (
<button
onClick={() => setEditing({ ...emptySet })}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700"
>
New multiplier set
</button>
)}
</div>
{/* Editor */}
{editing && (
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-gray-900">
{editing.id ? "Edit multiplier set" : "New multiplier set"}
</h2>
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Name</label>
<input
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm"
placeholder="e.g. CGI Standard Multipliers"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Description</label>
<input
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm"
placeholder="Optional description"
/>
</div>
</div>
<label className="mb-4 flex items-center gap-2 text-sm text-gray-600">
<input
type="checkbox"
checked={editing.isDefault}
onChange={(e) => setEditing({ ...editing, isDefault: e.target.checked })}
className="rounded border-gray-300"
/>
Default set (auto-selected when applying multipliers)
</label>
{/* Rules table */}
<div className="mb-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">Rules ({editing.rules.length})</h3>
<button
onClick={addRule}
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
>
+ Add rule
</button>
</div>
{editing.rules.length === 0 ? (
<p className="rounded-xl bg-gray-50 p-4 text-center text-sm text-gray-400">
No rules yet. Add rules to define rate and effort adjustments.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-2 font-medium">Chapter</th>
<th className="px-2 py-2 font-medium">Location</th>
<th className="px-2 py-2 font-medium">Level</th>
<th className="px-2 py-2 text-right font-medium">Cost mult.</th>
<th className="px-2 py-2 text-right font-medium">Bill mult.</th>
<th className="px-2 py-2 text-right font-medium">Shoring %</th>
<th className="px-2 py-2 text-right font-medium">Add. effort %</th>
<th className="pl-2 py-2 font-medium w-10"></th>
</tr>
</thead>
<tbody>
{editing.rules.map((rule, i) => (
<tr key={i} className="border-b border-gray-100">
<td className="py-2 pr-2">
<input
value={rule.chapter}
onChange={(e) => updateRule(i, { chapter: e.target.value })}
list="chapter-presets"
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
placeholder="Any"
/>
</td>
<td className="px-2 py-2">
<input
value={rule.location}
onChange={(e) => updateRule(i, { location: e.target.value })}
list="location-presets"
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
placeholder="Any"
/>
</td>
<td className="px-2 py-2">
<input
value={rule.level}
onChange={(e) => updateRule(i, { level: e.target.value })}
list="level-presets"
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
placeholder="Any"
/>
</td>
<td className="px-2 py-2">
<input
type="number"
step="0.01"
min="0"
value={rule.costMultiplier}
onChange={(e) => updateRule(i, { costMultiplier: parseFloat(e.target.value) || 0 })}
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
/>
</td>
<td className="px-2 py-2">
<input
type="number"
step="0.01"
min="0"
value={rule.billMultiplier}
onChange={(e) => updateRule(i, { billMultiplier: parseFloat(e.target.value) || 0 })}
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
/>
</td>
<td className="px-2 py-2">
<input
type="number"
step="0.01"
min="0"
max="1"
value={rule.shoringRatio}
onChange={(e) => updateRule(i, { shoringRatio: e.target.value })}
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
placeholder="-"
/>
</td>
<td className="px-2 py-2">
<input
type="number"
step="0.01"
min="0"
value={rule.additionalEffortRatio}
onChange={(e) => updateRule(i, { additionalEffortRatio: e.target.value })}
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
placeholder="-"
/>
</td>
<td className="pl-2 py-2">
<button
onClick={() => removeRule(i)}
className="text-red-400 hover:text-red-600"
title="Remove"
>
x
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<datalist id="chapter-presets">
{CHAPTER_PRESETS.map((d) => (
<option key={d} value={d} />
))}
</datalist>
<datalist id="location-presets">
{LOCATION_PRESETS.map((d) => (
<option key={d} value={d} />
))}
</datalist>
<datalist id="level-presets">
{LEVEL_PRESETS.map((d) => (
<option key={d} value={d} />
))}
</datalist>
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={isSaving || !editing.name.trim()}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{isSaving ? "Saving..." : "Save"}
</button>
<button
onClick={() => setEditing(null)}
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
>
Cancel
</button>
</div>
{(createMutation.error || updateMutation.error) && (
<p className="mt-2 text-sm text-red-600">
{createMutation.error?.message || updateMutation.error?.message}
</p>
)}
</div>
)}
{/* List */}
{isLoading && <p className="text-center text-sm text-gray-400">Loading...</p>}
{sets && sets.length === 0 && !editing && (
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-8 text-center text-sm text-gray-500">
No experience multiplier sets yet. Create one to define rate and effort adjustments.
</div>
)}
{sets?.map((s) => (
<div key={s.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-base font-semibold text-gray-900">{s.name}</h3>
{s.isDefault && (
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700">Default</span>
)}
<span className="text-sm text-gray-500">{s.rules.length} rules</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setExpandedId(expandedId === s.id ? null : s.id)}
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
>
{expandedId === s.id ? "Collapse" : "Expand"}
</button>
<button
onClick={() => handleEdit(s)}
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
>
Edit
</button>
<button
onClick={() => {
if (confirm(`Delete multiplier set "${s.name}"?`)) {
deleteMutation.mutate({ id: s.id });
}
}}
className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
>
Delete
</button>
</div>
</div>
{s.description && <p className="mt-1 text-sm text-gray-500">{s.description}</p>}
{expandedId === s.id && s.rules.length > 0 && (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Chapter</th>
<th className="px-3 py-2 font-medium">Location</th>
<th className="px-3 py-2 font-medium">Level</th>
<th className="px-3 py-2 text-right font-medium">Cost mult.</th>
<th className="px-3 py-2 text-right font-medium">Bill mult.</th>
<th className="px-3 py-2 text-right font-medium">Shoring</th>
<th className="pl-3 py-2 text-right font-medium">Add. effort</th>
</tr>
</thead>
<tbody>
{s.rules.map((r) => (
<tr key={r.id} className="border-b border-gray-100">
<td className="py-1.5 pr-3 text-gray-900">{r.chapter || "\u2014"}</td>
<td className="px-3 py-1.5 text-gray-700">{r.location || "\u2014"}</td>
<td className="px-3 py-1.5 text-gray-500">{r.level || "\u2014"}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">{r.costMultiplier.toFixed(2)}x</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">{r.billMultiplier.toFixed(2)}x</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-500">
{r.shoringRatio != null ? `${(r.shoringRatio * 100).toFixed(0)}%` : "\u2014"}
</td>
<td className="pl-3 py-1.5 text-right tabular-nums text-gray-500">
{r.additionalEffortRatio != null ? `${(r.additionalEffortRatio * 100).toFixed(0)}%` : "\u2014"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,322 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type LevelRow = { id: string; name: string; groupId: string };
type GroupRow = {
id: string;
name: string;
targetPercentage: number;
sortOrder: number;
levels: LevelRow[];
};
type EditingGroup = {
id?: string;
name: string;
targetPercentage: number;
sortOrder: number;
};
type EditingLevel = {
id?: string;
name: string;
groupId: string;
};
export function ManagementLevelsClient() {
const [editingGroup, setEditingGroup] = useState<EditingGroup | null>(null);
const [editingLevel, setEditingLevel] = useState<EditingLevel | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: groups, isLoading } = trpc.managementLevel.listGroups.useQuery();
const createGroupMut = trpc.managementLevel.createGroup.useMutation({
onSuccess: () => { void utils.managementLevel.listGroups.invalidate(); setEditingGroup(null); },
onError: (e) => setError(e.message),
});
const updateGroupMut = trpc.managementLevel.updateGroup.useMutation({
onSuccess: () => { void utils.managementLevel.listGroups.invalidate(); setEditingGroup(null); },
onError: (e) => setError(e.message),
});
const createLevelMut = trpc.managementLevel.createLevel.useMutation({
onSuccess: () => { void utils.managementLevel.listGroups.invalidate(); setEditingLevel(null); },
onError: (e) => setError(e.message),
});
const updateLevelMut = trpc.managementLevel.updateLevel.useMutation({
onSuccess: () => { void utils.managementLevel.listGroups.invalidate(); setEditingLevel(null); },
onError: (e) => setError(e.message),
});
const deleteLevelMut = trpc.managementLevel.deleteLevel.useMutation({
onSuccess: () => void utils.managementLevel.listGroups.invalidate(),
onError: (e) => setError(e.message),
});
function openCreateGroup() {
const maxOrder = Math.max(0, ...(groups ?? []).map((g) => (g as unknown as GroupRow).sortOrder));
setEditingGroup({ name: "", targetPercentage: 0, sortOrder: maxOrder + 1 });
setError(null);
}
function openEditGroup(g: GroupRow) {
setEditingGroup({ id: g.id, name: g.name, targetPercentage: g.targetPercentage, sortOrder: g.sortOrder });
setError(null);
}
function handleSaveGroup() {
if (!editingGroup) return;
setError(null);
if (editingGroup.id) {
updateGroupMut.mutate({
id: editingGroup.id,
data: { name: editingGroup.name, targetPercentage: editingGroup.targetPercentage, sortOrder: editingGroup.sortOrder },
});
} else {
createGroupMut.mutate({
name: editingGroup.name,
targetPercentage: editingGroup.targetPercentage,
sortOrder: editingGroup.sortOrder,
});
}
}
function openCreateLevel(groupId: string) {
setEditingLevel({ name: "", groupId });
setError(null);
}
function openEditLevel(l: LevelRow) {
setEditingLevel({ id: l.id, name: l.name, groupId: l.groupId });
setError(null);
}
function handleSaveLevel() {
if (!editingLevel) return;
setError(null);
if (editingLevel.id) {
updateLevelMut.mutate({
id: editingLevel.id,
data: { name: editingLevel.name, groupId: editingLevel.groupId },
});
} else {
createLevelMut.mutate({ name: editingLevel.name, groupId: editingLevel.groupId });
}
}
const isGroupPending = createGroupMut.isPending || updateGroupMut.isPending;
const isLevelPending = createLevelMut.isPending || updateLevelMut.isPending;
const rows = (groups ?? []) as unknown as GroupRow[];
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Management Levels</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Level groups with chargeability targets and individual levels
</p>
</div>
<button
type="button"
onClick={openCreateGroup}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
+ Add Group
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{error}
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
</div>
)}
{isLoading && <div className="text-center py-8 text-gray-400">Loading...</div>}
<div className="space-y-4">
{rows.map((group) => (
<div key={group.id} className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Group header */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{group.name}</h3>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400">
Target: {Math.round(group.targetPercentage * 100)}%
</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => openCreateLevel(group.id)}
className="text-xs text-green-600 hover:text-green-800 font-medium"
>
+ Level
</button>
<button
type="button"
onClick={() => openEditGroup(group)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit Group
</button>
</div>
</div>
{/* Levels */}
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{group.levels.length === 0 && (
<div className="px-4 py-3 text-sm text-gray-400">No levels in this group yet.</div>
)}
{group.levels.map((level) => (
<div key={level.id} className="flex items-center justify-between px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/30 group">
<span className="text-sm text-gray-900 dark:text-gray-100">{level.name}</span>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => openEditLevel(level)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
<button
type="button"
onClick={() => {
if (confirm(`Delete level "${level.name}"?`)) {
deleteLevelMut.mutate({ id: level.id });
}
}}
className="text-xs text-red-500 hover:text-red-700 font-medium"
>
Delete
</button>
</div>
</div>
))}
</div>
</div>
))}
{!isLoading && rows.length === 0 && (
<div className="text-center py-8 text-gray-400">No management level groups yet.</div>
)}
</div>
{/* Group Modal */}
{editingGroup && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingGroup.id ? "Edit Group" : "Add Group"}
</h2>
<button type="button" onClick={() => setEditingGroup(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
type="text"
value={editingGroup.name}
onChange={(e) => setEditingGroup({ ...editingGroup, name: e.target.value })}
placeholder="e.g. Senior Management"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Target % (0-100)</label>
<input
type="number"
value={Math.round(editingGroup.targetPercentage * 100)}
onChange={(e) => setEditingGroup({ ...editingGroup, targetPercentage: (parseInt(e.target.value) || 0) / 100 })}
min={0}
max={100}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
<input
type="number"
value={editingGroup.sortOrder}
onChange={(e) => setEditingGroup({ ...editingGroup, sortOrder: parseInt(e.target.value) || 0 })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditingGroup(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Cancel</button>
<button
type="button"
onClick={handleSaveGroup}
disabled={isGroupPending || !editingGroup.name}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isGroupPending ? "Saving..." : editingGroup.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
{/* Level Modal */}
{editingLevel && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-sm mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingLevel.id ? "Edit Level" : "Add Level"}
</h2>
<button type="button" onClick={() => setEditingLevel(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Level Name</label>
<input
type="text"
value={editingLevel.name}
onChange={(e) => setEditingLevel({ ...editingLevel, name: e.target.value })}
placeholder="e.g. Managing Director"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Group</label>
<select
value={editingLevel.groupId}
onChange={(e) => setEditingLevel({ ...editingLevel, groupId: e.target.value })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
>
{rows.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditingLevel(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Cancel</button>
<button
type="button"
onClick={handleSaveLevel}
disabled={isLevelPending || !editingLevel.name}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isLevelPending ? "Saving..." : editingLevel.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,282 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type OrgUnitRow = {
id: string;
name: string;
shortName: string | null;
level: number;
parentId: string | null;
sortOrder: number;
isActive: boolean;
};
type OrgUnitNode = OrgUnitRow & { children: OrgUnitNode[] };
type EditingUnit = {
id?: string;
name: string;
shortName: string;
level: number;
parentId: string;
sortOrder: number;
};
const LEVEL_LABELS: Record<number, string> = { 5: "L5 — Division", 6: "L6 — Department", 7: "L7 — Team" };
const LEVEL_COLORS: Record<number, string> = {
5: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400",
6: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
7: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400",
};
function TreeNode({
node,
onEdit,
depth = 0,
}: {
node: OrgUnitNode;
onEdit: (u: OrgUnitRow) => void;
depth?: number;
}) {
const [expanded, setExpanded] = useState(true);
const hasChildren = node.children.length > 0;
return (
<div>
<div
className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-800/30 rounded-lg group"
style={{ paddingLeft: `${depth * 24 + 12}px` }}
>
{hasChildren ? (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 text-xs"
>
{expanded ? "▼" : "▶"}
</button>
) : (
<span className="w-5" />
)}
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${LEVEL_COLORS[node.level] ?? "bg-gray-100 text-gray-600"}`}>
L{node.level}
</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 flex-1">
{node.name}
{node.shortName && <span className="text-gray-400 ml-1">({node.shortName})</span>}
</span>
{!node.isActive && (
<span className="text-xs text-gray-400 italic">inactive</span>
)}
<button
type="button"
onClick={() => onEdit(node)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium opacity-0 group-hover:opacity-100 transition-opacity"
>
Edit
</button>
</div>
{expanded && node.children.map((child) => (
<TreeNode key={child.id} node={child} onEdit={onEdit} depth={depth + 1} />
))}
</div>
);
}
export function OrgUnitsClient() {
const [editing, setEditing] = useState<EditingUnit | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: tree, isLoading } = trpc.orgUnit.getTree.useQuery();
const { data: flatList } = trpc.orgUnit.list.useQuery();
const createMut = trpc.orgUnit.create.useMutation({
onSuccess: () => { void utils.orgUnit.getTree.invalidate(); void utils.orgUnit.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const updateMut = trpc.orgUnit.update.useMutation({
onSuccess: () => { void utils.orgUnit.getTree.invalidate(); void utils.orgUnit.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const allUnits = (flatList ?? []) as unknown as OrgUnitRow[];
function openCreate(level: number, parentId?: string) {
setEditing({
name: "",
shortName: "",
level,
parentId: parentId ?? "",
sortOrder: 0,
});
setError(null);
}
function openEdit(u: OrgUnitRow) {
setEditing({
id: u.id,
name: u.name,
shortName: u.shortName ?? "",
level: u.level,
parentId: u.parentId ?? "",
sortOrder: u.sortOrder,
});
setError(null);
}
function handleSave() {
if (!editing) return;
setError(null);
if (editing.id) {
updateMut.mutate({
id: editing.id,
data: {
name: editing.name,
shortName: editing.shortName || undefined,
sortOrder: editing.sortOrder,
parentId: editing.parentId || undefined,
},
});
} else {
createMut.mutate({
name: editing.name,
shortName: editing.shortName || undefined,
level: editing.level,
parentId: editing.parentId || undefined,
sortOrder: editing.sortOrder,
});
}
}
// Possible parents for the editing unit (must be lower level number)
const possibleParents = editing
? allUnits.filter((u) => u.level < editing.level && u.isActive)
: [];
const isPending = createMut.isPending || updateMut.isPending;
const treeNodes = (tree ?? []) as unknown as OrgUnitNode[];
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Org Units</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
3-level hierarchy: L5 (Division) L6 (Department) L7 (Team)
</p>
</div>
<div className="flex gap-2">
<button type="button" onClick={() => openCreate(5)} className="px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium">+ L5</button>
<button type="button" onClick={() => openCreate(6)} className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium">+ L6</button>
<button type="button" onClick={() => openCreate(7)} className="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium">+ L7</button>
</div>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{error}
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
</div>
)}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-2">
{isLoading && <div className="text-center py-8 text-gray-400">Loading...</div>}
{!isLoading && treeNodes.length === 0 && (
<div className="text-center py-8 text-gray-400">No org units yet. Start by adding an L5 division.</div>
)}
{treeNodes.map((node) => (
<TreeNode key={node.id} node={node} onEdit={openEdit} />
))}
</div>
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Org Unit" : `Add ${LEVEL_LABELS[editing.level] ?? `L${editing.level}`}`}
</h2>
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
type="text"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
placeholder="e.g. Content Production"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Short Name</label>
<input
type="text"
value={editing.shortName}
onChange={(e) => setEditing({ ...editing, shortName: e.target.value })}
placeholder="CP"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
<input
type="number"
value={editing.sortOrder}
onChange={(e) => setEditing({ ...editing, sortOrder: parseInt(e.target.value) || 0 })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
{editing.level > 5 && (
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Unit</label>
<select
value={editing.parentId}
onChange={(e) => setEditing({ ...editing, parentId: e.target.value })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
>
<option value=""> No parent </option>
{possibleParents.map((p) => (
<option key={p.id} value={p.id}>
L{p.level} {p.name}
</option>
))}
</select>
</div>
)}
<div>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${LEVEL_COLORS[editing.level] ?? "bg-gray-100 text-gray-600"}`}>
{LEVEL_LABELS[editing.level] ?? `Level ${editing.level}`}
</span>
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditing(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Cancel</button>
<button
type="button"
onClick={handleSave}
disabled={isPending || !editing.name}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,788 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
// ─── Local types ────────────────────────────────────────────────────────────
type RateCardRow = {
id: string;
name: string;
currency: string;
effectiveFrom: string | null;
effectiveTo: string | null;
source: string | null;
isActive: boolean;
clientId: string | null;
client: { id: string; name: string; code: string | null } | null;
_count: { lines: number };
};
type RateCardLine = {
id: string;
rateCardId: string;
roleId: string | null;
chapter: string | null;
location: string | null;
seniority: string | null;
workType: string | null;
serviceGroup: string | null;
costRateCents: number;
billRateCents: number | null;
machineRateCents: number | null;
attributes: unknown;
role: { id: string; name: string; color: string | null } | null;
createdAt: string;
updatedAt: string;
};
type ClientOption = {
id: string;
name: string;
code: string | null;
};
type EditingCard = {
id?: string;
name: string;
currency: string;
effectiveFrom: string;
effectiveTo: string;
source: string;
clientId: string;
};
type EditingLine = {
id?: string;
roleId: string;
chapter: string;
location: string;
seniority: string;
workType: string;
serviceGroup: string;
costRateCents: number;
billRateCents: number;
machineRateCents: number;
};
const emptyCard: EditingCard = {
name: "",
currency: "EUR",
effectiveFrom: "",
effectiveTo: "",
source: "",
clientId: "",
};
const emptyLine: EditingLine = {
roleId: "",
chapter: "",
location: "",
seniority: "",
workType: "",
serviceGroup: "",
costRateCents: 0,
billRateCents: 0,
machineRateCents: 0,
};
function formatCents(cents: number | null | undefined): string {
if (cents == null) return "-";
return (cents / 100).toFixed(2);
}
function formatDate(d: string | null | undefined): string {
if (!d) return "-";
return new Date(d).toLocaleDateString("de-DE");
}
// ─── Component ──────────────────────────────────────────────────────────────
export function RateCardsClient() {
const [search, setSearch] = useState("");
const [filterClientId, setFilterClientId] = useState<string>("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [editingCard, setEditingCard] = useState<EditingCard | null>(null);
const [editingLine, setEditingLine] = useState<EditingLine | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
// ─── Queries ────────────────────────────────────────────────────────────
const { data: cards, isLoading } = trpc.rateCard.list.useQuery(
{
search: search || undefined,
...(filterClientId ? { clientId: filterClientId } : {}),
},
);
const { data: detail } = trpc.rateCard.getById.useQuery(
{ id: selectedId! },
{ enabled: !!selectedId },
);
const { data: roles } = trpc.role.list.useQuery({});
const { data: clientsData } = trpc.clientEntity.list.useQuery({});
// ─── Mutations ──────────────────────────────────────────────────────────
const invalidateAll = () => {
void utils.rateCard.list.invalidate();
if (selectedId) void utils.rateCard.getById.invalidate({ id: selectedId });
};
// Use bare useMutation() to avoid TS2589 deep inference (see LEARNINGS.md)
const createMut = trpc.rateCard.create.useMutation();
const updateMut = trpc.rateCard.update.useMutation();
const deactivateMut = trpc.rateCard.deactivate.useMutation();
const addLineMut = trpc.rateCard.addLine.useMutation();
const updateLineMut = trpc.rateCard.updateLine.useMutation();
const deleteLineMut = trpc.rateCard.deleteLine.useMutation();
// ─── Handlers ───────────────────────────────────────────────────────────
function openCreateCard() {
setEditingCard({ ...emptyCard });
setError(null);
}
function openEditCard() {
if (!detail) return;
setEditingCard({
id: detail.id,
name: detail.name,
currency: detail.currency,
effectiveFrom: detail.effectiveFrom
? new Date(detail.effectiveFrom).toISOString().slice(0, 10)
: "",
effectiveTo: detail.effectiveTo
? new Date(detail.effectiveTo).toISOString().slice(0, 10)
: "",
source: detail.source ?? "",
clientId: detail.clientId ?? "",
});
setError(null);
}
async function handleSaveCard() {
if (!editingCard) return;
setError(null);
try {
if (editingCard.id) {
await updateMut.mutateAsync({
id: editingCard.id,
data: {
name: editingCard.name,
currency: editingCard.currency,
...(editingCard.effectiveFrom
? { effectiveFrom: new Date(editingCard.effectiveFrom) }
: { effectiveFrom: null }),
...(editingCard.effectiveTo
? { effectiveTo: new Date(editingCard.effectiveTo) }
: { effectiveTo: null }),
source: editingCard.source || null,
clientId: editingCard.clientId || null,
},
});
invalidateAll();
setEditingCard(null);
} else {
const created = await createMut.mutateAsync({
name: editingCard.name,
currency: editingCard.currency,
...(editingCard.effectiveFrom
? { effectiveFrom: new Date(editingCard.effectiveFrom) }
: {}),
...(editingCard.effectiveTo
? { effectiveTo: new Date(editingCard.effectiveTo) }
: {}),
...(editingCard.source ? { source: editingCard.source } : {}),
...(editingCard.clientId ? { clientId: editingCard.clientId } : {}),
lines: [],
});
invalidateAll();
setEditingCard(null);
setSelectedId(created.id);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save rate card");
}
}
function openAddLine() {
setEditingLine({ ...emptyLine });
setError(null);
}
function openEditLine(line: RateCardLine) {
setEditingLine({
id: line.id,
roleId: line.roleId ?? "",
chapter: line.chapter ?? "",
location: line.location ?? "",
seniority: line.seniority ?? "",
workType: line.workType ?? "",
serviceGroup: line.serviceGroup ?? "",
costRateCents: line.costRateCents,
billRateCents: line.billRateCents ?? 0,
machineRateCents: line.machineRateCents ?? 0,
});
setError(null);
}
async function handleSaveLine() {
if (!editingLine || !selectedId) return;
setError(null);
const lineData = {
...(editingLine.roleId ? { roleId: editingLine.roleId } : {}),
...(editingLine.chapter ? { chapter: editingLine.chapter } : {}),
...(editingLine.location ? { location: editingLine.location } : {}),
...(editingLine.seniority ? { seniority: editingLine.seniority } : {}),
...(editingLine.workType ? { workType: editingLine.workType } : {}),
...(editingLine.serviceGroup ? { serviceGroup: editingLine.serviceGroup } : {}),
costRateCents: editingLine.costRateCents,
...(editingLine.billRateCents ? { billRateCents: editingLine.billRateCents } : {}),
...(editingLine.machineRateCents ? { machineRateCents: editingLine.machineRateCents } : {}),
attributes: {},
};
try {
if (editingLine.id) {
await updateLineMut.mutateAsync({ lineId: editingLine.id, data: lineData });
} else {
await addLineMut.mutateAsync({ rateCardId: selectedId, line: lineData });
}
invalidateAll();
setEditingLine(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save rate line");
}
}
async function handleDeleteLine(lineId: string) {
if (!confirm("Delete this rate line?")) return;
try {
await deleteLineMut.mutateAsync({ lineId });
invalidateAll();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to delete rate line");
}
}
async function handleDeactivate(id: string) {
if (!confirm("Deactivate this rate card?")) return;
try {
await deactivateMut.mutateAsync({ id });
invalidateAll();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to deactivate rate card");
}
}
async function handleReactivate(id: string) {
try {
await updateMut.mutateAsync({ id, data: { isActive: true } });
invalidateAll();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to reactivate rate card");
}
}
const isPending =
createMut.isPending ||
updateMut.isPending ||
addLineMut.isPending ||
updateLineMut.isPending ||
deleteLineMut.isPending;
const cardList = (cards ?? []) as unknown as RateCardRow[];
const lines = ((detail?.lines ?? []) as unknown as RateCardLine[]);
const roleList = (roles ?? []) as unknown as { id: string; name: string; color: string | null }[];
const clientList = (clientsData ?? []) as unknown as ClientOption[];
// ─── Render ─────────────────────────────────────────────────────────────
return (
<div className="p-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Rate Cards</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage cost and billing rates per role, chapter, and seniority
</p>
</div>
<button
type="button"
onClick={openCreateCard}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
+ New Rate Card
</button>
</div>
{/* Error banner */}
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{error}
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
</div>
)}
<div className="flex gap-6">
{/* ─── Left: Card list ─────────────────────────────────────────── */}
<div className="w-80 shrink-0">
<div className="mb-3 space-y-2">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search rate cards..."
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-full bg-white dark:bg-gray-900 dark:text-gray-100"
/>
<select
value={filterClientId}
onChange={(e) => setFilterClientId(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-full bg-white dark:bg-gray-900 dark:text-gray-100"
>
<option value="">All clients</option>
{clientList.map((c) => (
<option key={c.id} value={c.id}>
{c.code ? `${c.code}${c.name}` : c.name}
</option>
))}
</select>
</div>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800">
{isLoading && <div className="text-center py-8 text-gray-400">Loading...</div>}
{!isLoading && cardList.length === 0 && (
<div className="text-center py-8 text-gray-400">
{search ? "No rate cards match your search." : "No rate cards yet."}
</div>
)}
{cardList.map((card) => (
<button
key={card.id}
type="button"
onClick={() => setSelectedId(card.id)}
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors ${
selectedId === card.id
? "bg-brand-50 dark:bg-brand-900/20 border-l-2 border-brand-600"
: ""
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{card.name}
</span>
{!card.isActive && (
<span className="text-xs text-gray-400 italic ml-2 shrink-0">inactive</span>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400">
<span>{card.currency}</span>
<span>{card._count.lines} lines</span>
{card.effectiveFrom && (
<span>from {formatDate(card.effectiveFrom)}</span>
)}
</div>
{card.client && (
<div className="mt-1 text-xs text-brand-600 dark:text-brand-400">
{card.client.code ? `${card.client.code}${card.client.name}` : card.client.name}
</div>
)}
</button>
))}
</div>
</div>
{/* ─── Right: Detail ───────────────────────────────────────────── */}
<div className="flex-1 min-w-0">
{!selectedId && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center text-gray-400">
Select a rate card to view details, or create a new one.
</div>
)}
{selectedId && detail && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700">
{/* Card header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{detail.name}
</h2>
<div className="flex items-center gap-4 mt-1 text-sm text-gray-500 dark:text-gray-400">
<span>{detail.currency}</span>
{detail.clientId && (detail as unknown as RateCardRow).client && (
<span>Client: {(detail as unknown as RateCardRow).client!.name}</span>
)}
{detail.source && <span>Source: {detail.source}</span>}
<span>
{detail.effectiveFrom
? formatDate(detail.effectiveFrom as unknown as string)
: "Open start"}{" "}
&mdash;{" "}
{detail.effectiveTo
? formatDate(detail.effectiveTo as unknown as string)
: "Open end"}
</span>
{!detail.isActive && (
<span className="text-amber-600 dark:text-amber-400 font-medium">Inactive</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={openEditCard}
className="px-3 py-1.5 text-sm text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300 font-medium"
>
Edit
</button>
{detail.isActive ? (
<button
type="button"
onClick={() => handleDeactivate(detail.id)}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-red-600 font-medium"
>
Deactivate
</button>
) : (
<button
type="button"
onClick={() => handleReactivate(detail.id)}
className="px-3 py-1.5 text-sm text-green-600 hover:text-green-800 font-medium"
>
Reactivate
</button>
)}
</div>
</div>
</div>
{/* Lines header */}
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
Rate Lines ({lines.length})
</h3>
<button
type="button"
onClick={openAddLine}
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-xs font-medium"
>
+ Add Line
</button>
</div>
{/* Lines table */}
<div className="overflow-x-auto">
{lines.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
No rate lines yet. Add one to get started.
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-800">
<th className="px-4 py-2 font-medium">Role</th>
<th className="px-4 py-2 font-medium">Chapter</th>
<th className="px-4 py-2 font-medium">Location</th>
<th className="px-4 py-2 font-medium">Seniority</th>
<th className="px-4 py-2 font-medium">Work Type</th>
<th className="px-4 py-2 font-medium text-right">Cost Rate</th>
<th className="px-4 py-2 font-medium text-right">Bill Rate</th>
<th className="px-4 py-2 font-medium text-right">Machine Rate</th>
<th className="px-4 py-2 font-medium w-20"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{lines.map((line) => (
<tr key={line.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30 group">
<td className="px-4 py-2 text-gray-900 dark:text-gray-100">
{line.role?.name ?? <span className="text-gray-400">-</span>}
</td>
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.chapter || "-"}</td>
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.location || "-"}</td>
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.seniority || "-"}</td>
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.workType || "-"}</td>
<td className="px-4 py-2 text-right font-mono text-gray-900 dark:text-gray-100">
{formatCents(line.costRateCents)}
</td>
<td className="px-4 py-2 text-right font-mono text-gray-700 dark:text-gray-300">
{formatCents(line.billRateCents)}
</td>
<td className="px-4 py-2 text-right font-mono text-gray-700 dark:text-gray-300">
{formatCents(line.machineRateCents)}
</td>
<td className="px-4 py-2">
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity justify-end">
<button
type="button"
onClick={() => openEditLine(line)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
<button
type="button"
onClick={() => handleDeleteLine(line.id)}
className="text-xs text-red-500 hover:text-red-700 font-medium"
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
</div>
{/* ─── Rate Card Modal ─────────────────────────────────────────────── */}
{editingCard && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingCard.id ? "Edit Rate Card" : "New Rate Card"}
</h2>
<button type="button" onClick={() => setEditingCard(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
type="text"
value={editingCard.name}
onChange={(e) => setEditingCard({ ...editingCard, name: e.target.value })}
placeholder="e.g. Standard 2026"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Client</label>
<select
value={editingCard.clientId}
onChange={(e) => setEditingCard({ ...editingCard, clientId: e.target.value })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
>
<option value="">-- No client --</option>
{clientList.map((c) => (
<option key={c.id} value={c.id}>
{c.code ? `${c.code}${c.name}` : c.name}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Currency</label>
<input
type="text"
value={editingCard.currency}
onChange={(e) => setEditingCard({ ...editingCard, currency: e.target.value.toUpperCase() })}
placeholder="EUR"
maxLength={3}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Source</label>
<input
type="text"
value={editingCard.source}
onChange={(e) => setEditingCard({ ...editingCard, source: e.target.value })}
placeholder="e.g. Finance dept"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective From</label>
<input
type="date"
value={editingCard.effectiveFrom}
onChange={(e) => setEditingCard({ ...editingCard, effectiveFrom: e.target.value })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective To</label>
<input
type="date"
value={editingCard.effectiveTo}
onChange={(e) => setEditingCard({ ...editingCard, effectiveTo: e.target.value })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditingCard(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Cancel</button>
<button
type="button"
onClick={handleSaveCard}
disabled={isPending || !editingCard.name || editingCard.currency.length !== 3}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : editingCard.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
{/* ─── Rate Line Modal ─────────────────────────────────────────────── */}
{editingLine && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-lg mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingLine.id ? "Edit Rate Line" : "Add Rate Line"}
</h2>
<button type="button" onClick={() => setEditingLine(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Role</label>
<select
value={editingLine.roleId}
onChange={(e) => setEditingLine({ ...editingLine, roleId: e.target.value })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
>
<option value="">-- No specific role --</option>
{roleList.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Chapter</label>
<input
type="text"
value={editingLine.chapter}
onChange={(e) => setEditingLine({ ...editingLine, chapter: e.target.value })}
placeholder="e.g. Animation"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Location</label>
<input
type="text"
value={editingLine.location}
onChange={(e) => setEditingLine({ ...editingLine, location: e.target.value })}
placeholder="e.g. Munich"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Seniority</label>
<input
type="text"
value={editingLine.seniority}
onChange={(e) => setEditingLine({ ...editingLine, seniority: e.target.value })}
placeholder="e.g. Senior"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Work Type</label>
<input
type="text"
value={editingLine.workType}
onChange={(e) => setEditingLine({ ...editingLine, workType: e.target.value })}
placeholder="e.g. Onsite"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Service Group</label>
<input
type="text"
value={editingLine.serviceGroup}
onChange={(e) => setEditingLine({ ...editingLine, serviceGroup: e.target.value })}
placeholder="e.g. Post Production"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cost Rate (cents)</label>
<input
type="number"
value={editingLine.costRateCents}
onChange={(e) => setEditingLine({ ...editingLine, costRateCents: parseInt(e.target.value) || 0 })}
min={0}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
<span className="text-xs text-gray-400 mt-0.5 block">= {formatCents(editingLine.costRateCents)}</span>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Bill Rate (cents)</label>
<input
type="number"
value={editingLine.billRateCents}
onChange={(e) => setEditingLine({ ...editingLine, billRateCents: parseInt(e.target.value) || 0 })}
min={0}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
<span className="text-xs text-gray-400 mt-0.5 block">= {formatCents(editingLine.billRateCents)}</span>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Machine Rate (cents)</label>
<input
type="number"
value={editingLine.machineRateCents}
onChange={(e) => setEditingLine({ ...editingLine, machineRateCents: parseInt(e.target.value) || 0 })}
min={0}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
<span className="text-xs text-gray-400 mt-0.5 block">= {formatCents(editingLine.machineRateCents)}</span>
</div>
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditingLine(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Cancel</button>
<button
type="button"
onClick={handleSaveLine}
disabled={isPending || editingLine.costRateCents <= 0}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : editingLine.id ? "Update Line" : "Add Line"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,928 @@
"use client";
import { useState, useEffect } from "react";
import { trpc } from "~/lib/trpc/client.js";
const INPUT_CLASS =
"w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100";
const LABEL_CLASS = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
type Provider = "openai" | "azure";
const ALL_ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const;
type SystemRole = typeof ALL_ROLES[number];
interface ScoreWeights {
skillDepth: number;
skillBreadth: number;
costEfficiency: number;
chargeability: number;
experience: number;
}
type ParsedAzureUrl = {
endpoint: string;
apiVersion: string;
deployment: string | null; // null for Responses API URLs (deployment not in path)
urlType: "completions" | "responses";
};
/** Parse endpoint, deployment, and api-version out of an Azure URL.
* Supports both Chat Completions and Responses API formats. */
function parseAzureUrl(raw: string): ParsedAzureUrl | null {
try {
const url = new URL(raw);
const endpoint = `${url.protocol}//${url.host}`;
const apiVersion = url.searchParams.get("api-version") ?? "2025-01-01-preview";
// Chat Completions: /openai/deployments/{name}/chat/completions
const completionsMatch = url.pathname.match(/\/openai\/deployments\/([^/]+)\//);
if (completionsMatch) {
return { endpoint, apiVersion, deployment: completionsMatch[1]!, urlType: "completions" };
}
// Responses API: /openai/responses
if (url.pathname.includes("/openai/responses")) {
return { endpoint, apiVersion, deployment: null, urlType: "responses" };
}
return null;
} catch {
return null;
}
}
export function SystemSettingsClient() {
const [provider, setProvider] = useState<Provider>("openai");
const [endpoint, setEndpoint] = useState("");
const [model, setModel] = useState("");
const [apiVersion, setApiVersion] = useState("2025-01-01-preview");
const [apiKey, setApiKey] = useState("");
const [maxTokens, setMaxTokens] = useState(2000);
const [temperature, setTemperature] = useState(1);
const [summaryPrompt, setSummaryPrompt] = useState("");
const [saved, setSaved] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; error?: string; raw?: string | null } | null>(null);
const [urlPasteValue, setUrlPasteValue] = useState("");
const [urlParseError, setUrlParseError] = useState(false);
const [urlParsedType, setUrlParsedType] = useState<"completions" | "responses" | null>(null);
// Value Score settings
const [scoreWeights, setScoreWeights] = useState<ScoreWeights>({
skillDepth: 0.30,
skillBreadth: 0.15,
costEfficiency: 0.25,
chargeability: 0.15,
experience: 0.15,
});
const [scoreVisibleRoles, setScoreVisibleRoles] = useState<SystemRole[]>(["ADMIN", "MANAGER"]);
const [scoreSaved, setScoreSaved] = useState(false);
const [recomputeResult, setRecomputeResult] = useState<{ updated: number } | null>(null);
// SMTP settings
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState(587);
const [smtpUser, setSmtpUser] = useState("");
const [smtpPassword, setSmtpPassword] = useState("");
const [smtpFrom, setSmtpFrom] = useState("");
const [smtpTls, setSmtpTls] = useState(true);
const [smtpSaved, setSmtpSaved] = useState(false);
const [smtpTestResult, setSmtpTestResult] = useState<{ ok: boolean; error?: string } | null>(null);
// Vacation defaults
const [vacationDefaultDays, setVacationDefaultDays] = useState(28);
const [vacationSaved, setVacationSaved] = useState(false);
const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, {
staleTime: 0,
});
useEffect(() => {
if (settings) {
setProvider((settings.aiProvider ?? "openai") as Provider);
setEndpoint(settings.azureOpenAiEndpoint ?? "");
setModel(settings.azureOpenAiDeployment ?? "");
setApiVersion(settings.azureApiVersion ?? "2025-01-01-preview");
setMaxTokens(settings.aiMaxCompletionTokens ?? 2000);
setTemperature(settings.aiTemperature ?? 1);
setSummaryPrompt(settings.aiSummaryPrompt ?? "");
if (settings.scoreWeights) {
setScoreWeights(settings.scoreWeights as ScoreWeights);
}
if (settings.scoreVisibleRoles) {
setScoreVisibleRoles(settings.scoreVisibleRoles as SystemRole[]);
}
// SMTP
setSmtpHost(settings.smtpHost ?? "");
setSmtpPort(settings.smtpPort ?? 587);
setSmtpUser(settings.smtpUser ?? "");
setSmtpFrom(settings.smtpFrom ?? "");
setSmtpTls(settings.smtpTls ?? true);
// Vacation
setVacationDefaultDays(settings.vacationDefaultDays ?? 28);
}
}, [settings]);
function handleUrlPaste(raw: string) {
setUrlPasteValue(raw);
if (!raw) { setUrlParseError(false); setUrlParsedType(null); return; }
const parsed = parseAzureUrl(raw);
if (parsed) {
setEndpoint(parsed.endpoint);
setApiVersion(parsed.apiVersion);
if (parsed.deployment) setModel(parsed.deployment);
setUrlParseError(false);
setUrlParsedType(parsed.urlType);
setUrlPasteValue("");
} else {
setUrlParseError(true);
setUrlParsedType(null);
}
}
const updateMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setSaved(true);
setTestResult(null);
setTimeout(() => setSaved(false), 3000);
},
});
const testMutation = trpc.settings.testAiConnection.useMutation({
onSuccess: (data) => setTestResult(data),
onError: (err) => setTestResult({ ok: false, error: err.message }),
});
const saveScoreMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setScoreSaved(true);
setTimeout(() => setScoreSaved(false), 3000);
},
});
const recomputeMutation = trpc.resource.recomputeValueScores.useMutation({
onSuccess: (data) => setRecomputeResult(data),
});
const saveSmtpMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setSmtpSaved(true);
setSmtpTestResult(null);
setTimeout(() => setSmtpSaved(false), 3000);
},
});
const testSmtpMutation = trpc.settings.testSmtpConnection.useMutation({
onSuccess: (data) => setSmtpTestResult(data),
onError: (err) => setSmtpTestResult({ ok: false, error: err.message }),
});
const saveVacationMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setVacationSaved(true);
setTimeout(() => setVacationSaved(false), 3000);
},
});
function handleSaveSmtp() {
saveSmtpMutation.mutate({
smtpHost: smtpHost || undefined,
smtpPort,
smtpUser: smtpUser || undefined,
...(smtpPassword ? { smtpPassword } : {}),
smtpFrom: smtpFrom || undefined,
smtpTls,
});
}
function handleSaveVacation() {
saveVacationMutation.mutate({ vacationDefaultDays });
}
function handleSaveScoreSettings() {
saveScoreMutation.mutate({ scoreWeights, scoreVisibleRoles });
}
function updateWeight(key: keyof ScoreWeights, value: number) {
setScoreWeights((prev) => ({ ...prev, [key]: value }));
}
function toggleRole(role: SystemRole) {
setScoreVisibleRoles((prev) =>
prev.includes(role) ? prev.filter((r) => r !== role) : [...prev, role],
);
}
const weightSum = Object.values(scoreWeights).reduce((s, v) => s + v, 0);
const weightSumOk = Math.abs(weightSum - 1.0) < 0.01;
function handleSave() {
updateMutation.mutate({
aiProvider: provider,
azureOpenAiEndpoint: provider === "azure" ? endpoint : "",
azureOpenAiDeployment: model,
azureApiVersion: provider === "azure" ? apiVersion : undefined,
aiMaxCompletionTokens: maxTokens,
aiTemperature: temperature,
aiSummaryPrompt: summaryPrompt || undefined,
...(apiKey ? { azureOpenAiApiKey: apiKey } : {}),
});
}
if (isLoading) {
return <div className="p-6 animate-pulse"><div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" /></div>;
}
return (
<div className="p-4 sm:p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">System Settings</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Configure AI integration for skill profile generation.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wider">AI Provider</h2>
{/* Provider toggle */}
<div>
<label className={LABEL_CLASS}>Provider</label>
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-gray-600 w-fit">
<button
type="button"
onClick={() => { setProvider("openai"); setTestResult(null); }}
className={`px-4 py-2 text-sm font-medium transition-colors ${
provider === "openai"
? "bg-brand-600 text-white"
: "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600"
}`}
>
OpenAI
</button>
<button
type="button"
onClick={() => { setProvider("azure"); setTestResult(null); }}
className={`px-4 py-2 text-sm font-medium border-l border-gray-200 dark:border-gray-600 transition-colors ${
provider === "azure"
? "bg-brand-600 text-white"
: "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600"
}`}
>
Azure OpenAI
</button>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1.5">
{provider === "openai"
? "Use a standard OpenAI API key from platform.openai.com."
: "Use a deployment on your own Azure OpenAI resource."}
</p>
</div>
{/* Azure-only fields */}
{provider === "azure" && (
<>
{/* Paste full URL shortcut */}
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 px-4 py-3 space-y-2">
<p className="text-xs font-medium text-blue-800 dark:text-blue-300">
Paste a full completion URL to auto-fill all fields below:
</p>
<input
type="url"
className={`${INPUT_CLASS} border-blue-300 dark:border-blue-600`}
placeholder="https://…cognitiveservices.azure.com/openai/deployments/gpt-5/chat/completions?api-version=2025-01-01-preview"
value={urlPasteValue}
onChange={(e) => handleUrlPaste(e.target.value)}
/>
{urlParseError && (
<p className="text-xs text-red-600 dark:text-red-400">
Could not parse URL expected either a Chat Completions URL
(<code className="font-mono">/openai/deployments//chat/completions</code>)
or a Responses API URL (<code className="font-mono">/openai/responses</code>).
</p>
)}
{urlParsedType === "responses" && (
<p className="text-xs text-amber-700 dark:text-amber-400">
Responses API URL detected endpoint and api-version filled in.
Enter the <strong>deployment/model name</strong> manually below (it is not part of this URL).
</p>
)}
{urlParsedType === "completions" && (
<p className="text-xs text-green-700 dark:text-green-400">
All fields filled from URL.
</p>
)}
</div>
<div>
<label className={LABEL_CLASS} htmlFor="ai-endpoint">Endpoint (base URL)</label>
<input
id="ai-endpoint"
type="url"
className={INPUT_CLASS}
placeholder="https://myinstance.cognitiveservices.azure.com"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Everything up to (not including) <code className="font-mono">/openai/</code>
</p>
</div>
</>
)}
{/* Model / deployment name */}
<div>
<label className={LABEL_CLASS} htmlFor="ai-model">
{provider === "azure" ? "Deployment Name" : "Model Name"}
</label>
<input
id="ai-model"
type="text"
className={INPUT_CLASS}
placeholder={provider === "azure" ? "my-gpt4o-deployment" : "gpt-4o-mini"}
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{provider === "azure"
? "The deployment name you chose when deploying the model in Azure."
: "The model identifier, e.g. gpt-4o-mini, gpt-4o, gpt-3.5-turbo."}
</p>
</div>
{/* Azure-only: api version */}
{provider === "azure" && (
<div>
<label className={LABEL_CLASS} htmlFor="ai-api-version">API Version</label>
<input
id="ai-api-version"
type="text"
className={INPUT_CLASS}
placeholder="2025-01-01-preview"
value={apiVersion}
onChange={(e) => setApiVersion(e.target.value)}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
The <code className="font-mono">api-version</code> query parameter from your endpoint URL. Default: <code className="font-mono">2025-01-01-preview</code>
</p>
</div>
)}
{/* API key */}
<div>
<label className={LABEL_CLASS} htmlFor="ai-key">API Key</label>
<input
id="ai-key"
type="password"
className={INPUT_CLASS}
placeholder={
settings?.hasApiKey
? "●●●●●●●●●●●● (already set — enter new value to replace)"
: provider === "openai"
? "sk-..."
: "Enter Azure API key"
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
autoComplete="new-password"
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{provider === "openai"
? "Your secret key from platform.openai.com → API keys. Starts with sk-."
: "One of the two keys from Azure Portal → your resource → Keys and Endpoint."}
{settings?.hasApiKey && " Leave blank to keep the existing key."}
</p>
</div>
{/* Test result */}
{testResult && (
<div
className={`rounded-lg px-4 py-3 text-sm ${
testResult.ok
? "bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 text-green-700 dark:text-green-300"
: "bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-300"
}`}
>
{testResult.ok ? (
<span className="font-medium">Connection successful AI summaries are ready to use.</span>
) : (
<div className="space-y-2">
<p><span className="font-medium">Connection failed:</span> {testResult.error}</p>
{testResult.raw && (
<details className="text-xs">
<summary className="cursor-pointer opacity-70 hover:opacity-100">Show raw error</summary>
<pre className="mt-1 p-2 bg-red-100 dark:bg-red-950 rounded text-red-800 dark:text-red-200 whitespace-pre-wrap break-all font-mono">
{testResult.raw}
</pre>
</details>
)}
</div>
)}
</div>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleSave}
disabled={updateMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{updateMutation.isPending ? "Saving…" : "Save Settings"}
</button>
<button
type="button"
onClick={() => { setTestResult(null); testMutation.mutate(); }}
disabled={testMutation.isPending}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm font-medium disabled:opacity-50"
>
{testMutation.isPending ? "Testing…" : "Test Connection"}
</button>
{saved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
</div>
</div>
{/* Generation settings */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wider">Generation Settings</h2>
{/* Max completion tokens */}
<div>
<div className="flex items-center justify-between mb-1">
<label className={LABEL_CLASS} htmlFor="ai-max-tokens">Max Completion Tokens</label>
<span className="text-sm font-mono text-brand-600 dark:text-brand-400">{maxTokens}</span>
</div>
<input
id="ai-max-tokens"
type="range"
min={50}
max={2000}
step={50}
value={maxTokens}
onChange={(e) => setMaxTokens(Number(e.target.value))}
className="w-full accent-brand-600"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>500</span>
<span className="text-gray-500 dark:text-gray-400">
{maxTokens < 1000 ? "⚠ May be empty for reasoning models (GPT-5, o1, o3)" :
maxTokens <= 2000 ? "Recommended for reasoning models ✓" :
maxTokens <= 4000 ? "High — allows longer bios" :
"Very high"}
</span>
<span>16000</span>
</div>
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">
Reasoning models (GPT-5, o1, o3) consume tokens internally before writing output. Set to at least 2000 to avoid empty responses.
</p>
</div>
{/* Temperature */}
<div>
<div className="flex items-center justify-between mb-1">
<label className={LABEL_CLASS} htmlFor="ai-temperature">Temperature</label>
<span className="text-sm font-mono text-brand-600 dark:text-brand-400">{temperature.toFixed(1)}</span>
</div>
<input
id="ai-temperature"
type="range"
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(e) => setTemperature(Number(e.target.value))}
className="w-full accent-brand-600"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>0 deterministic</span>
<span className="text-gray-500 dark:text-gray-400">
{temperature <= 0.3 ? "Factual & consistent" :
temperature <= 0.9 ? "Balanced" :
temperature <= 1.0 ? "Default (1) — recommended ✓" :
temperature <= 1.2 ? "Creative" :
"Very creative / unpredictable"}
</span>
<span>2 creative</span>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Some models (e.g. GPT-5) only accept the default value of 1. If generation fails with a temperature error, the system retries automatically without it.
</p>
</div>
{/* Summary prompt */}
<div>
<div className="flex items-center justify-between mb-1">
<label className={LABEL_CLASS} htmlFor="ai-prompt">Profile Summary Prompt</label>
{summaryPrompt && (
<button
type="button"
onClick={() => setSummaryPrompt("")}
className="text-xs text-brand-600 hover:underline"
>
Reset to default
</button>
)}
</div>
<textarea
id="ai-prompt"
rows={10}
value={summaryPrompt || (settings?.defaultSummaryPrompt ?? "")}
onChange={(e) => setSummaryPrompt(e.target.value)}
className={`${INPUT_CLASS} font-mono text-xs leading-relaxed resize-y`}
spellCheck={false}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Available placeholders: <code className="font-mono">{"{role}"}</code> <code className="font-mono">{"{chapter}"}</code> <code className="font-mono">{"{mainSkills}"}</code> <code className="font-mono">{"{topSkills}"}</code>
</p>
</div>
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={handleSave}
disabled={updateMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{updateMutation.isPending ? "Saving…" : "Save Settings"}
</button>
{saved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
</div>
</div>
</div>{/* end 2-col grid */}
{/* Value Score Settings */}
<div className="mt-6 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wider mb-1">Value Score</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
A persistent 0100 <em>price/quality</em> metric per resource five weighted dimensions combined.
Recompute on demand after changing weights or importing new skill matrices.
</p>
</div>
<div className="flex-shrink-0 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 px-3 py-2 font-mono text-[11px] text-gray-700 dark:text-gray-300 whitespace-nowrap">
round(<span className="text-brand-600 dark:text-brand-400">D</span>·w +{" "}
<span className="text-brand-600 dark:text-brand-400">B</span>·w +{" "}
<span className="text-brand-600 dark:text-brand-400">C</span>·w +{" "}
<span className="text-brand-600 dark:text-brand-400">A</span>·w +{" "}
<span className="text-brand-600 dark:text-brand-400">E</span>·w)
</div>
</div>
{/* Weight sliders — compact grid */}
<div>
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Dimension Weights must sum to 100%
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3">
{/* Skill Depth */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">D</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Skill Depth</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.skillDepth * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.skillDepth * 100)}
onChange={(e) => updateWeight("skillDepth", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Average proficiency (15) across all skills, scaled to 0100. Expert-heavy profiles score near 100.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
D = round((avg_proficiency / 5) × 100)
<span className="ml-2 text-gray-400">avg 4.0/5 80</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = all Beginner · 100 = all Expert
</div>
</div>
</details>
</div>
{/* Skill Breadth */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">B</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Skill Breadth</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.skillBreadth * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.skillBreadth * 100)}
onChange={(e) => updateWeight("skillBreadth", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Number of distinct skills listed. 10 pts per skill, caps at 100 (10+ skills). Rewards versatile generalists.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
B = min(100, skill_count × 10)
<span className="ml-2 text-gray-400">7 skills 70</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = no skills · 100 = 10+ skills
</div>
</div>
</details>
</div>
{/* Cost Efficiency */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">C</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Cost Efficiency</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.costEfficiency * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.costEfficiency * 100)}
onChange={(e) => updateWeight("costEfficiency", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Inverse LCR vs org-wide max. Cheapest resource = 100, most expensive = 0. Core "price" component.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
C = round((1 LCR / max_LCR) × 100)
<span className="ml-2 text-gray-400">60 vs 120 50</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = highest LCR · 100 = lowest LCR
</div>
<p className="text-[11px] text-amber-600 dark:text-amber-500">
If all resources share the same LCR, everyone scores 0 on this dimension.
</p>
</div>
</details>
</div>
{/* Chargeability */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">A</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Chargeability</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.chargeability * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.chargeability * 100)}
onChange={(e) => updateWeight("chargeability", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Distance from personal chargeability target (90-day window). On target = 100; 2 pts per pp off.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
A = max(0, 100 |target% actual%| × 2)
<span className="ml-2 text-gray-400">10 pp off 80</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = 50+ pp off target · 100 = exactly on target
</div>
<p className="text-[11px] text-gray-400 dark:text-gray-500">
New resources with no allocations: actual = 0%, score reflects gap from target.
</p>
</div>
</details>
</div>
{/* Experience */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">E</span>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Experience</span>
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
{Math.round(scoreWeights.experience * 100)}%
</span>
</div>
<input
type="range" min={0} max={100} step={5}
value={Math.round(scoreWeights.experience * 100)}
onChange={(e) => updateWeight("experience", Number(e.target.value) / 100)}
className="w-full accent-brand-600"
/>
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
<div className="mt-2 space-y-1.5">
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
Average years of experience across skills with explicit years data from skill-matrix imports. Capped at 10 years.
</p>
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
E = min(100, avg_years × 10)
<span className="ml-2 text-gray-400">6.5 yrs 65 · 10+ yrs 100</span>
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
0 = no years data · 100 = 10+ years average
</div>
</div>
</details>
</div>
</div>
</div>
{/* Weight sum indicator */}
<div className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border ${
weightSumOk
? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700 text-green-700 dark:text-green-300"
: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-700 text-red-700 dark:text-red-300"
}`}>
<span>{weightSumOk ? "✓" : "✗"}</span>
<span>
Total weight: <span className="font-mono">{Math.round(weightSum * 100)}%</span>
{weightSumOk ? " — valid" : " — must be exactly 100% to save"}
</span>
</div>
{/* Visibility roles */}
<div className="space-y-2">
<label className={LABEL_CLASS}>Score visibility which roles can see the Value Score</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Controls who sees the Score column on the Resources list, the breakdown on the Resource Detail page,
and the Top Value Resources dashboard widget.
</p>
<div className="flex flex-wrap gap-3 mt-2">
{ALL_ROLES.map((role) => (
<label key={role} className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none">
<input
type="checkbox"
checked={scoreVisibleRoles.includes(role)}
onChange={() => toggleRole(role)}
className="rounded border-gray-300"
/>
{role}
</label>
))}
</div>
</div>
{/* Recompute */}
<div className="border-t border-gray-100 dark:border-gray-700 pt-5 space-y-3">
<div>
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">Recompute Scores</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Scores are <strong>not updated automatically</strong> run this after changing weights or after importing
new skill matrices. The computation fetches all active resources and their last 90 days of allocations,
then writes the result back to each resource record.
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => { setRecomputeResult(null); recomputeMutation.mutate(); }}
disabled={recomputeMutation.isPending}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm font-medium disabled:opacity-50"
>
{recomputeMutation.isPending ? "Recomputing…" : "Recompute All Scores"}
</button>
{recomputeResult && (
<span className="text-sm text-green-600 dark:text-green-400 font-medium">
Updated {recomputeResult.updated} resource{recomputeResult.updated !== 1 ? "s" : ""}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={handleSaveScoreSettings}
disabled={saveScoreMutation.isPending || !weightSumOk}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{saveScoreMutation.isPending ? "Saving…" : "Save Score Settings"}
</button>
{scoreSaved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
</div>
</div>
{/* ── SMTP / Email ──────────────────────────────────────────── */}
<div className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Email Notifications (SMTP)</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Used to send email notifications when vacation requests are approved or rejected.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS}>SMTP Host</label>
<input type="text" className={INPUT_CLASS} value={smtpHost} onChange={(e) => setSmtpHost(e.target.value)} placeholder="smtp.example.com" />
</div>
<div>
<label className={LABEL_CLASS}>SMTP Port</label>
<input type="number" className={INPUT_CLASS} value={smtpPort} onChange={(e) => setSmtpPort(parseInt(e.target.value, 10))} min={1} max={65535} />
</div>
<div>
<label className={LABEL_CLASS}>SMTP Username</label>
<input type="text" className={INPUT_CLASS} value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} placeholder="user@example.com" autoComplete="off" />
</div>
<div>
<label className={LABEL_CLASS}>
SMTP Password{" "}
{settings?.hasSmtpPassword && <span className="text-gray-400 font-normal text-xs">(set leave blank to keep)</span>}
</label>
<input type="password" className={INPUT_CLASS} value={smtpPassword} onChange={(e) => setSmtpPassword(e.target.value)} placeholder="••••••••" autoComplete="new-password" />
</div>
<div>
<label className={LABEL_CLASS}>From Address</label>
<input type="email" className={INPUT_CLASS} value={smtpFrom} onChange={(e) => setSmtpFrom(e.target.value)} placeholder="noreply@planarchy.app" />
</div>
<div className="flex items-center gap-2 pt-6">
<input type="checkbox" id="smtpTls" checked={smtpTls} onChange={(e) => setSmtpTls(e.target.checked)} className="rounded border-gray-300 text-brand-600" />
<label htmlFor="smtpTls" className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer">Use TLS</label>
</div>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSaveSmtp}
disabled={saveSmtpMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{saveSmtpMutation.isPending ? "Saving…" : "Save SMTP Settings"}
</button>
<button
type="button"
onClick={() => testSmtpMutation.mutate()}
disabled={testSmtpMutation.isPending}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm font-medium disabled:opacity-50"
>
{testSmtpMutation.isPending ? "Testing…" : "Test Connection"}
</button>
{smtpSaved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
{smtpTestResult && (
<span className={`text-sm font-medium ${smtpTestResult.ok ? "text-green-600 dark:text-green-400" : "text-red-500 dark:text-red-400"}`}>
{smtpTestResult.ok ? "✓ Connection successful" : `${smtpTestResult.error}`}
</span>
)}
</div>
</div>
{/* ── Vacation Defaults ─────────────────────────────────────── */}
<div className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Vacation Defaults</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Default annual leave entitlement for new resources and the entitlement bulk-set tool.
</p>
</div>
<div className="max-w-xs">
<label className={LABEL_CLASS}>Default Annual Leave Days</label>
<input
type="number"
className={INPUT_CLASS}
value={vacationDefaultDays}
onChange={(e) => setVacationDefaultDays(parseInt(e.target.value, 10))}
min={0}
max={365}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Applied when creating new entitlement records for resources.</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSaveVacation}
disabled={saveVacationMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{saveVacationMutation.isPending ? "Saving…" : "Save Vacation Settings"}
</button>
{vacationSaved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
</div>
</div>
</div>
);
}
@@ -0,0 +1,536 @@
"use client";
import { useState } from "react";
import { SystemRole, PermissionKey, type PermissionOverrides } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
const PERMISSION_LABELS: Record<string, string> = {
viewCosts: "View Costs",
exportData: "Export Data",
importData: "Import Data",
approveVacations: "Approve Vacations",
manageBlueprints: "Manage Blueprints",
viewAllResources: "View All Resources",
manageResources: "Manage Resources",
manageProjects: "Manage Projects",
manageAllocations: "Manage Allocations",
manageRoles: "Manage Roles",
manageUsers: "Manage Users",
};
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
[SystemRole.ADMIN]: "Admin",
[SystemRole.MANAGER]: "Manager",
[SystemRole.CONTROLLER]: "Controller",
[SystemRole.USER]: "User",
[SystemRole.VIEWER]: "Viewer",
};
const ROLE_BADGE_COLORS: Record<SystemRole, string> = {
[SystemRole.ADMIN]: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400",
[SystemRole.MANAGER]: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
[SystemRole.CONTROLLER]: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400",
[SystemRole.USER]: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
[SystemRole.VIEWER]: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500",
};
// Lower = more privileged (sort asc = most privileged first)
const ROLE_ORDER: Record<string, number> = {
ADMIN: 0,
MANAGER: 1,
CONTROLLER: 2,
USER: 3,
VIEWER: 4,
};
type UserRow = {
id: string;
name: string | null;
email: string;
systemRole: string;
createdAt: Date;
};
type EditState = {
userId: string;
systemRole: SystemRole;
granted: Set<string>;
denied: Set<string>;
chapterIds: string;
};
export function UsersClient() {
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [editState, setEditState] = useState<EditState | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
const utils = trpc.useUtils();
const { data: users, isLoading } = trpc.user.list.useQuery(undefined, {
staleTime: 10_000,
});
const { data: effectivePerms } = trpc.user.getEffectivePermissions.useQuery(
{ userId: selectedUserId ?? "" },
{ enabled: !!selectedUserId },
);
const updateRoleMutation = trpc.user.updateRole.useMutation({
onSuccess: async () => {
await utils.user.list.invalidate();
await utils.user.getEffectivePermissions.invalidate();
},
onError: (err) => setActionError(err.message),
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TS2589: tRPC infers union type too deeply for nullable overrides schema
const setPermissionsMutation = trpc.user.setPermissions.useMutation({
onSuccess: async () => {
await utils.user.list.invalidate();
await utils.user.getEffectivePermissions.invalidate();
},
onError: (err) => setActionError(err.message),
});
const resetPermissionsMutation = trpc.user.resetPermissions.useMutation({
onSuccess: async () => {
await utils.user.list.invalidate();
await utils.user.getEffectivePermissions.invalidate();
if (editState) {
setEditState({ ...editState, granted: new Set(), denied: new Set(), chapterIds: "" });
}
},
onError: (err) => setActionError(err.message),
});
function openEdit(user: UserRow) {
const role = (user.systemRole as SystemRole) ?? SystemRole.USER;
setSelectedUserId(user.id);
setEditState({
userId: user.id,
systemRole: role,
granted: new Set(),
denied: new Set(),
chapterIds: "",
});
setActionError(null);
}
function closeEdit() {
setSelectedUserId(null);
setEditState(null);
setActionError(null);
}
function toggleGranted(key: string) {
if (!editState) return;
const next = new Set(editState.granted);
const nextDenied = new Set(editState.denied);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
nextDenied.delete(key);
}
setEditState({ ...editState, granted: next, denied: nextDenied });
}
function toggleDenied(key: string) {
if (!editState) return;
const next = new Set(editState.denied);
const nextGranted = new Set(editState.granted);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
nextGranted.delete(key);
}
setEditState({ ...editState, denied: next, granted: nextGranted });
}
async function handleSaveRole() {
if (!editState) return;
setActionError(null);
await updateRoleMutation.mutateAsync({ id: editState.userId, systemRole: editState.systemRole });
}
async function handleSavePermissions() {
if (!editState) return;
setActionError(null);
const granted = Array.from(editState.granted);
const denied = Array.from(editState.denied);
const chapterIds = editState.chapterIds
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const overrides: PermissionOverrides = {
...(granted.length > 0 ? { granted: granted as unknown as PermissionKey[] } : {}),
...(denied.length > 0 ? { denied: denied as unknown as PermissionKey[] } : {}),
...(chapterIds.length > 0 ? { chapterIds } : {}),
};
const hasOverrides = granted.length > 0 || denied.length > 0 || chapterIds.length > 0;
await setPermissionsMutation.mutateAsync({
userId: editState.userId,
overrides: hasOverrides ? overrides : null,
});
}
async function handleReset() {
if (!editState) return;
setActionError(null);
await resetPermissionsMutation.mutateAsync({ userId: editState.userId });
}
const allUsers = (users ?? []) as unknown as UserRow[];
// Client-side filtering
const filteredUsers = allUsers.filter((u) => {
if (search) {
const q = search.toLowerCase();
if (!(u.name ?? "").toLowerCase().includes(q) && !u.email.toLowerCase().includes(q)) return false;
}
if (roleFilter && u.systemRole !== roleFilter) return false;
return true;
});
const usersViewPrefs = useViewPrefs("users");
const { sorted, sortField, sortDir, toggle } = useTableSort(filteredUsers, {
initialField: usersViewPrefs.savedSort?.field ?? null,
initialDir: usersViewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
usersViewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
function handleSort(field: string) {
if (field === "systemRole") {
toggle("systemRole", (u) => ROLE_ORDER[u.systemRole] ?? 99);
} else {
toggle(field as keyof UserRow);
}
}
const selectedUser = editState ? allUsers.find((u) => u.id === editState.userId) : null;
const isPending =
updateRoleMutation.isPending ||
setPermissionsMutation.isPending ||
resetPermissionsMutation.isPending;
function clearAll() {
setSearch("");
setRoleFilter("");
}
const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(roleFilter ? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }] : []),
];
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">User Management</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage user roles and permission overrides
</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 mb-3">
<input
type="search"
placeholder="Search by name or email…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64 bg-white dark:bg-gray-900 dark:text-gray-100"
/>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value as SystemRole | "")}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 bg-white dark:bg-gray-900 dark:text-gray-100"
>
<option value="">All Roles</option>
{Object.values(SystemRole).map((role) => (
<option key={role} value={role}>{SYSTEM_ROLE_LABELS[role]}</option>
))}
</select>
</div>
{chips.length > 0 && (
<div className="mb-3">
<FilterChips chips={chips} onClearAll={clearAll} />
</div>
)}
{actionError && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{actionError}
<button
type="button"
onClick={() => setActionError(null)}
className="text-red-400 hover:text-red-600 text-lg leading-none"
>
&times;
</button>
</div>
)}
{/* User Table */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
<SortableColumnHeader label="Email" field="email" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
<SortableColumnHeader label="Role" field="systemRole" sortField={sortField} sortDir={sortDir} onSort={handleSort} align="center" tooltip="System role determines default access level. ADMIN: full access · MANAGER: manage resources/projects · CONTROLLER: read + export costs · USER: standard access · VIEWER: read-only. Individual permissions can be overridden via the edit dialog." tooltipWidth="w-80" />
<SortableColumnHeader label="Created" field="createdAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Account creation date." />
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={5} className="text-center py-8 text-gray-400">
Loading
</td>
</tr>
)}
{!isLoading && sorted.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-8 text-gray-400">
No users found.
</td>
</tr>
)}
{sorted.map((user) => (
<tr
key={user.id}
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"
>
<td className="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
{user.name ?? <span className="italic text-gray-400"></span>}
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{user.email}</td>
<td className="px-4 py-3 text-center">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
ROLE_BADGE_COLORS[user.systemRole as SystemRole] ?? ROLE_BADGE_COLORS[SystemRole.USER]
}`}
>
{SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole}
</span>
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
{new Date(user.createdAt).toLocaleDateString("en-GB")}
</td>
<td className="px-4 py-3 text-right">
<button
type="button"
onClick={() => openEdit(user)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Edit Modal */}
{editState && selectedUser && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-2xl mx-4 flex flex-col max-h-[90vh]">
{/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Edit User
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{selectedUser.name ?? selectedUser.email}
</p>
</div>
<button
type="button"
onClick={closeEdit}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
>
&times;
</button>
</div>
{/* Modal Body */}
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-6">
{/* System Role */}
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
System Role
</h3>
<div className="flex items-center gap-3">
<select
value={editState.systemRole}
onChange={(e) =>
setEditState({ ...editState, systemRole: e.target.value as SystemRole })
}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
>
{Object.values(SystemRole).map((role) => (
<option key={role} value={role}>
{SYSTEM_ROLE_LABELS[role]}
</option>
))}
</select>
<button
type="button"
onClick={handleSaveRole}
disabled={isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{updateRoleMutation.isPending ? "Saving…" : "Save Role"}
</button>
</div>
</section>
{/* Effective Permissions */}
{effectivePerms && (
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Effective Permissions
</h3>
<div className="flex flex-wrap gap-1.5">
{ALL_PERMISSION_KEYS.map((key) => {
const isActive = effectivePerms.effectivePermissions.includes(key);
return (
<span
key={key}
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
isActive
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400"
: "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600 line-through"
}`}
>
{PERMISSION_LABELS[key] ?? key}
</span>
);
})}
</div>
</section>
)}
{/* Permission Overrides */}
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Permission Overrides
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Additional Grants */}
<div>
<p className="text-xs font-medium text-green-700 dark:text-green-400 mb-2 uppercase tracking-wide">
Additional Grants
</p>
<div className="space-y-1.5">
{ALL_PERMISSION_KEYS.map((key) => (
<label
key={`grant-${key}`}
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
>
<input
type="checkbox"
checked={editState.granted.has(key)}
onChange={() => toggleGranted(key)}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
{PERMISSION_LABELS[key] ?? key}
</label>
))}
</div>
</div>
{/* Explicit Denials */}
<div>
<p className="text-xs font-medium text-red-700 dark:text-red-400 mb-2 uppercase tracking-wide">
Explicit Denials
</p>
<div className="space-y-1.5">
{ALL_PERMISSION_KEYS.map((key) => (
<label
key={`deny-${key}`}
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
>
<input
type="checkbox"
checked={editState.denied.has(key)}
onChange={() => toggleDenied(key)}
className="rounded border-gray-300 text-red-600 focus:ring-red-500"
/>
{PERMISSION_LABELS[key] ?? key}
</label>
))}
</div>
</div>
</div>
{/* Chapter Scope */}
<div className="mt-4">
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
Chapter Scope (comma-separated IDs, leave blank for all)
</label>
<input
type="text"
value={editState.chapterIds}
onChange={(e) => setEditState({ ...editState, chapterIds: e.target.value })}
placeholder="e.g. chapter-1, chapter-2"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</section>
</div>
{/* Modal Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button
type="button"
onClick={handleReset}
disabled={isPending}
className="px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-200 dark:border-red-700 hover:border-red-300 dark:hover:border-red-600 rounded-lg disabled:opacity-50"
>
{resetPermissionsMutation.isPending ? "Resetting…" : "Reset to Defaults"}
</button>
<div className="flex gap-3">
<button
type="button"
onClick={closeEdit}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
Close
</button>
<button
type="button"
onClick={handleSavePermissions}
disabled={isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{setPermissionsMutation.isPending ? "Saving…" : "Save Permissions"}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,243 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type CategoryRow = {
id: string;
code: string;
name: string;
description: string | null;
sortOrder: number;
isDefault: boolean;
isActive: boolean;
};
type EditingCategory = {
id?: string;
code: string;
name: string;
description: string;
sortOrder: number;
isDefault: boolean;
};
const emptyCategory: EditingCategory = {
code: "",
name: "",
description: "",
sortOrder: 0,
isDefault: false,
};
export function UtilizationCategoriesClient() {
const [editing, setEditing] = useState<EditingCategory | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: categories, isLoading } = trpc.utilizationCategory.list.useQuery();
const createMut = trpc.utilizationCategory.create.useMutation({
onSuccess: () => { void utils.utilizationCategory.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const updateMut = trpc.utilizationCategory.update.useMutation({
onSuccess: () => { void utils.utilizationCategory.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
function openCreate() {
const maxOrder = Math.max(0, ...(categories ?? []).map((c) => (c as unknown as CategoryRow).sortOrder));
setEditing({ ...emptyCategory, sortOrder: maxOrder + 1 });
setError(null);
}
function openEdit(c: CategoryRow) {
setEditing({
id: c.id,
code: c.code,
name: c.name,
description: c.description ?? "",
sortOrder: c.sortOrder,
isDefault: c.isDefault,
});
setError(null);
}
function handleSave() {
if (!editing) return;
setError(null);
if (editing.id) {
updateMut.mutate({
id: editing.id,
data: {
code: editing.code,
name: editing.name,
description: editing.description || undefined,
sortOrder: editing.sortOrder,
isDefault: editing.isDefault,
},
});
} else {
createMut.mutate({
code: editing.code,
name: editing.name,
description: editing.description || undefined,
sortOrder: editing.sortOrder,
isDefault: editing.isDefault,
});
}
}
const isPending = createMut.isPending || updateMut.isPending;
const rows = (categories ?? []) as unknown as CategoryRow[];
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Utilization Categories</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Categories assigned to projects for chargeability reporting
</p>
</div>
<button
type="button"
onClick={openCreate}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
+ Add Category
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{error}
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
</div>
)}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Code</th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Name</th>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Description</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Default</th>
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Order</th>
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr><td colSpan={6} className="text-center py-8 text-gray-400">Loading...</td></tr>
)}
{!isLoading && rows.length === 0 && (
<tr><td colSpan={6} className="text-center py-8 text-gray-400">No categories yet.</td></tr>
)}
{rows.map((c) => (
<tr key={c.id} className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-4 py-3 font-mono font-medium text-gray-900 dark:text-gray-100">{c.code}</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{c.name}</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs max-w-xs truncate">{c.description ?? "—"}</td>
<td className="px-4 py-3 text-center">
{c.isDefault && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400">Default</span>
)}
</td>
<td className="px-4 py-3 text-center text-gray-400">{c.sortOrder}</td>
<td className="px-4 py-3 text-right">
<button type="button" onClick={() => openEdit(c)} className="text-xs text-brand-600 hover:text-brand-800 font-medium">Edit</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Category" : "Add Category"}
</h2>
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code</label>
<input
type="text"
value={editing.code}
onChange={(e) => setEditing({ ...editing, code: e.target.value })}
placeholder="Chg"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
<input
type="number"
value={editing.sortOrder}
onChange={(e) => setEditing({ ...editing, sortOrder: parseInt(e.target.value) || 0 })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
type="text"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
placeholder="Chargeable"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
<textarea
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
rows={2}
placeholder="Revenue-generating client project work"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 resize-none"
/>
</div>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={editing.isDefault}
onChange={(e) => setEditing({ ...editing, isDefault: e.target.checked })}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Default category for new projects
</label>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditing(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Cancel</button>
<button
type="button"
onClick={handleSave}
disabled={isPending || !editing.code || !editing.name}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,483 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { AllocationStatus } from "@planarchy/shared";
import type { AllocationWithDetails, RecurrencePattern } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { RecurrenceEditor } from "./RecurrenceEditor.js";
const ALLOCATION_STATUSES = Object.values(AllocationStatus);
type EntryKind = "demand" | "assignment";
interface AllocationModalProps {
allocation?: AllocationWithDetails | null;
onClose: () => void;
onSuccess: () => void;
}
function toDateInputValue(date: Date | string | null | undefined): string {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
return d.toISOString().split("T")[0] ?? "";
}
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
const isEditing = Boolean(allocation);
const initialEntryKind: EntryKind = allocation && !allocation.resourceId ? "demand" : "assignment";
const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind);
const isDemandEntry = entryKind === "demand";
const [resourceId, setResourceId] = useState(allocation?.resourceId ?? "");
const [projectId, setProjectId] = useState(allocation?.projectId ?? "");
const [roleId, setRoleId] = useState(allocation?.roleId ?? "");
const [roleFreeText, setRoleFreeText] = useState(allocation?.role ?? "");
const [headcount, setHeadcount] = useState(allocation?.headcount ?? 1);
const [startDate, setStartDate] = useState(toDateInputValue(allocation?.startDate));
const [endDate, setEndDate] = useState(toDateInputValue(allocation?.endDate));
const [hoursPerDay, setHoursPerDay] = useState<number>(allocation?.hoursPerDay ?? 8);
const [status, setStatus] = useState<AllocationStatus>(
allocation?.status ?? AllocationStatus.PROPOSED,
);
const existingMeta = allocation?.metadata as Record<string, unknown> | undefined;
const [isRecurring, setIsRecurring] = useState<boolean>(!!existingMeta?.recurrence);
const [recurrence, setRecurrence] = useState<RecurrencePattern | undefined>(
existingMeta?.recurrence as RecurrencePattern | undefined,
);
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const { data: projects } = trpc.project.list.useQuery(
{ limit: 500 },
{ staleTime: 60_000 },
);
const { data: rolesData } = trpc.role.list.useQuery(
{ isActive: true },
{ staleTime: 60_000 },
);
const utils = trpc.useUtils();
const invalidatePlanningViews = () => {
void utils.allocation.list.invalidate();
void (utils as { allocation: { listView: { invalidate: () => Promise<unknown> } } }).allocation.listView.invalidate();
void utils.allocation.listDemands.invalidate();
void utils.allocation.listAssignments.invalidate();
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createDemandMutation = (trpc.allocation.createDemandRequirement.useMutation as any)({
onSuccess: () => {
invalidatePlanningViews();
onSuccess();
},
onError: (err: { message: string }) => {
setServerError(err.message);
},
}) as {
isPending: boolean;
isError: boolean;
error?: { message: string };
mutate: (input: unknown) => void;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createAssignmentMutation = (trpc.allocation.createAssignment.useMutation as any)({
onSuccess: () => {
invalidatePlanningViews();
onSuccess();
},
onError: (err: { message: string }) => {
setServerError(err.message);
},
}) as {
isPending: boolean;
isError: boolean;
error?: { message: string };
mutate: (input: unknown) => void;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateMutation = (trpc.allocation.update.useMutation as any)({
onSuccess: () => {
invalidatePlanningViews();
onSuccess();
},
onError: (err: { message: string }) => {
setServerError(err.message);
},
}) as {
isPending: boolean;
isError: boolean;
error?: { message: string };
mutate: (input: unknown) => void;
};
const isPending =
createDemandMutation.isPending ||
createAssignmentMutation.isPending ||
updateMutation.isPending;
useEffect(() => {
setServerError(null);
}, [resourceId, projectId, roleId, roleFreeText, startDate, endDate, hoursPerDay, status, entryKind]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setServerError(null);
if (!projectId) {
setServerError("Please select a project.");
return;
}
if (!isDemandEntry && !resourceId) {
setServerError("Please select a resource.");
return;
}
if (!startDate || !endDate) {
setServerError("Please fill in start and end dates.");
return;
}
const start = new Date(startDate);
const end = new Date(endDate);
if (end < start) {
setServerError("End date must be on or after start date.");
return;
}
const baseMeta = (allocation?.metadata as Record<string, unknown> | undefined) ?? {};
const metadata: Record<string, unknown> = {
...baseMeta,
...(isRecurring && recurrence ? { recurrence } : { recurrence: undefined }),
};
if (!isRecurring) delete metadata.recurrence;
// Determine role string from roleId if set
const rolesList = rolesData ?? [];
const selectedRole = rolesList.find((r) => r.id === roleId);
const roleString = selectedRole ? selectedRole.name : (roleFreeText || undefined);
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
if (isEditing && allocation) {
updateMutation.mutate({
id: getPlanningEntryMutationId(allocation),
data: {
resourceId: isDemandEntry ? undefined : (resourceId || undefined),
projectId,
role: roleString,
roleId: roleId || undefined,
headcount: isDemandEntry ? headcount : 1,
startDate: start,
endDate: end,
hoursPerDay,
percentage,
status: status as AllocationStatus,
metadata,
},
});
} else if (isDemandEntry) {
createDemandMutation.mutate({
projectId,
role: roleString,
roleId: roleId || undefined,
headcount,
startDate: start,
endDate: end,
hoursPerDay,
percentage,
status: status as AllocationStatus,
metadata,
});
} else {
createAssignmentMutation.mutate({
resourceId,
projectId,
role: roleString,
roleId: roleId || undefined,
startDate: start,
endDate: end,
hoursPerDay,
percentage,
status: status as AllocationStatus,
metadata,
});
}
}
const inputClass =
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const resourceList = (resources?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
const projectList = (projects?.projects ?? []) as Array<{ id: string; name: string; shortCode: string }>;
const rolesList = (rolesData ?? []) as Array<{ id: string; name: string; color: string | null }>;
const entryLabel = isDemandEntry ? "Open Demand" : "Assignment";
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-xl mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isEditing ? `Edit ${entryLabel}` : `New ${entryLabel}`}
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors text-xl leading-none"
aria-label="Close"
>
&times;
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
{/* Demand toggle */}
<div className="flex items-center gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<label className="flex items-center gap-2 text-sm cursor-pointer select-none flex-1">
<input
type="checkbox"
checked={isDemandEntry}
disabled={isEditing}
onChange={(e) => {
setEntryKind(e.target.checked ? "demand" : "assignment");
if (e.target.checked) setResourceId("");
}}
className="rounded border-gray-300 dark:border-gray-600 disabled:cursor-not-allowed disabled:opacity-60"
/>
<div>
<span className="font-medium text-gray-800 dark:text-gray-100">Open demand</span>
<span className="block text-xs text-gray-500 dark:text-gray-400">
{isEditing
? "Demand vs assignment type is fixed after creation during the compatibility migration."
: "No resource assigned yet and tracked as staffing demand"}
</span>
</div>
</label>
{isDemandEntry && (
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Headcount:</label>
<input
type="number"
value={headcount}
onChange={(e) => setHeadcount(Math.max(1, Number(e.target.value)))}
min={1}
max={50}
className="w-16 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm text-center dark:bg-gray-900 dark:text-gray-100"
/>
</div>
)}
</div>
{/* Resource is only required for assignments */}
{!isDemandEntry && (
<div>
<label htmlFor="modal-resource" className={labelClass}>
Resource <span className="text-red-500">*</span>
</label>
<select
id="modal-resource"
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
className={inputClass}
required={!isDemandEntry}
>
<option value="">Select a resource</option>
{resourceList.map((r) => (
<option key={r.id} value={r.id}>
{r.displayName} ({r.eid})
</option>
))}
</select>
</div>
)}
{/* Project */}
<div>
<label htmlFor="modal-project" className={labelClass}>
Project <span className="text-red-500">*</span>
</label>
<select
id="modal-project"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className={inputClass}
required
>
<option value="">Select a project</option>
{projectList.map((p) => (
<option key={p.id} value={p.id}>
{p.shortCode} {p.name}
</option>
))}
</select>
</div>
{/* Role */}
<div>
<label htmlFor="modal-role" className={labelClass}>Role</label>
<select
id="modal-role"
value={roleId}
onChange={(e) => setRoleId(e.target.value)}
className={inputClass}
>
<option value="">No role / custom</option>
{rolesList.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
{!roleId && (
<input
type="text"
value={roleFreeText}
onChange={(e) => setRoleFreeText(e.target.value)}
placeholder="Or type a custom role…"
className={`${inputClass} mt-1`}
maxLength={200}
/>
)}
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="modal-start" className={labelClass}>
Start Date <span className="text-red-500">*</span>
</label>
<DateInput
id="modal-start"
value={startDate}
onChange={setStartDate}
className={inputClass}
required
/>
</div>
<div>
<label htmlFor="modal-end" className={labelClass}>
End Date <span className="text-red-500">*</span>
</label>
<DateInput
id="modal-end"
value={endDate}
onChange={setEndDate}
min={startDate}
className={inputClass}
required
/>
</div>
</div>
{/* Hours/Day + Status */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="modal-hours" className={labelClass}>
Hours / Day
</label>
<input
id="modal-hours"
type="number"
value={hoursPerDay}
onChange={(e) => setHoursPerDay(Number(e.target.value))}
min={0.5}
max={8}
step={0.5}
className={inputClass}
/>
</div>
<div>
<label htmlFor="modal-status" className={labelClass}>
Status
</label>
<select
id="modal-status"
value={status}
onChange={(e) => setStatus(e.target.value as AllocationStatus)}
className={inputClass}
>
{ALLOCATION_STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
{/* Recurring toggle */}
<div>
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<input
type="checkbox"
checked={isRecurring}
onChange={(e) => {
setIsRecurring(e.target.checked);
if (!e.target.checked) setRecurrence(undefined);
}}
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="font-medium text-gray-700 dark:text-gray-300">Recurring schedule</span>
</label>
{isRecurring && (
<div className="mt-2">
<RecurrenceEditor value={recurrence} onChange={setRecurrence} />
</div>
)}
</div>
{/* Server error */}
{serverError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{serverError}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving…" : "Save"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,606 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { formatDate } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
import { AllocationModal } from "./AllocationModal.js";
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, ColumnDef } from "@planarchy/shared";
import { AllocationStatus, ALLOCATION_COLUMNS } from "@planarchy/shared";
import { useSelection } from "~/hooks/useSelection.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { FilterBar } from "~/components/ui/FilterBar.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
import { ColumnTogglePanel } from "~/components/ui/ColumnTogglePanel.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
const STATUS_BADGE: Record<string, string> = {
ACTIVE: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
PROPOSED: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
CONFIRMED: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
COMPLETED: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400",
CANCELLED: "bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400",
};
const ALL_ALLOC_STATUSES = [
{ value: "PROPOSED", label: "Proposed" },
{ value: "CONFIRMED", label: "Confirmed" },
{ value: "ACTIVE", label: "Active" },
{ value: "COMPLETED", label: "Completed" },
{ value: "CANCELLED", label: "Cancelled" },
] as const;
type AllocationAssignmentsView = AllocationReadModel<AllocationLike>;
type DemandRow = AllocationWithDetails & {
sourceAllocationId?: string;
requestedHeadcount?: number;
unfilledHeadcount?: number;
};
export function AllocationsClient() {
const [modalOpen, setModalOpen] = useState(false);
const [editingAllocation, setEditingAllocation] = useState<AllocationWithDetails | null>(null);
const [filterProjectId, setFilterProjectId] = useState<string>("");
const [filterResourceId, setFilterResourceId] = useState<string>("");
const [filterStatus, setFilterStatus] = useState<string>("");
const [hidePastProjects, setHidePastProjects] = useState(true);
const [hideCompletedProjects, setHideCompletedProjects] = useState(true);
const [hideDraftProjects, setHideDraftProjects] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<{ single?: AllocationWithDetails; ids?: string[] } | null>(null);
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
const selection = useSelection();
const utils = trpc.useUtils();
const { canViewCosts } = usePermissions();
// ─── Column visibility ────────────────────────────────────────────────────
const baseColumns = useMemo<ColumnDef[]>(
() => (canViewCosts ? ALLOCATION_COLUMNS : ALLOCATION_COLUMNS.filter((c) => c.key !== "cost")),
[canViewCosts],
);
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig("allocations", baseColumns);
const defaultKeys = useMemo(() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key), [baseColumns]);
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
{
projectId: filterProjectId || undefined,
resourceId: filterResourceId || undefined,
status: (filterStatus as AllocationStatus) || undefined,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ placeholderData: (prev: any) => prev, staleTime: 15_000 },
) as { data: AllocationAssignmentsView | undefined; isLoading: boolean };
const deleteDemandMutation = trpc.allocation.deleteDemandRequirement.useMutation({
onSuccess: async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
},
});
const deleteAssignmentMutation = trpc.allocation.deleteAssignment.useMutation({
onSuccess: async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
},
});
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
onSuccess: async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
selection.clear();
},
});
const batchStatusMutation = trpc.allocation.batchUpdateStatus.useMutation({
onSuccess: async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
selection.clear();
},
});
useEffect(() => {
selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterProjectId, filterResourceId, filterStatus, hidePastProjects, hideCompletedProjects, hideDraftProjects]);
function openCreate() {
setEditingAllocation(null);
setModalOpen(true);
}
function openEdit(alloc: AllocationWithDetails) {
setEditingAllocation(alloc);
setModalOpen(true);
}
function closeModal() {
setModalOpen(false);
setEditingAllocation(null);
}
const assignmentList = (allocationView?.assignments ?? []) as unknown as AllocationWithDetails[];
const demandList = (allocationView?.demands ?? []) as unknown as DemandRow[];
const today = new Date();
today.setHours(0, 0, 0, 0);
const filteredAllocations = assignmentList.filter((alloc) => {
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today) return false;
if (hideCompletedProjects && alloc.project?.status && ["COMPLETED", "CANCELLED"].includes(alloc.project.status)) return false;
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
return true;
});
const filteredDemands = demandList.filter((alloc) => {
if (filterResourceId) return false;
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today) return false;
if (hideCompletedProjects && alloc.project?.status && ["COMPLETED", "CANCELLED"].includes(alloc.project.status)) return false;
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
return true;
});
const allocViewPrefs = useViewPrefs("allocations");
const { sorted, sortField, sortDir, toggle } = useTableSort(filteredAllocations, {
initialField: allocViewPrefs.savedSort?.field ?? null,
initialDir: allocViewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
allocViewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
const allocationIds = sorted.map((a) => a.id);
const allocationMutationIdsByDisplayId = useMemo(
() =>
new Map(
sorted.map((allocation) => [allocation.id, getPlanningEntryMutationId(allocation)]),
),
[sorted],
);
const selectedMutationIds = useMemo(
() =>
selection.selectedArray.flatMap((displayId) => {
const mutationId = allocationMutationIdsByDisplayId.get(displayId);
return mutationId ? [mutationId] : [];
}),
[allocationMutationIdsByDisplayId, selection.selectedArray],
);
function handleSort(field: string) {
if (field === "resource") {
toggle("resource", (a) => a.resource?.displayName ?? null);
} else if (field === "project") {
toggle("project", (a) => a.project?.name ?? null);
} else {
toggle(field);
}
}
function clearAll() {
setFilterProjectId("");
setFilterResourceId("");
setFilterStatus("");
setHidePastProjects(false);
setHideCompletedProjects(false);
setHideDraftProjects(false);
}
const chips = [
...(filterProjectId ? [{ label: `Project filter active`, onRemove: () => setFilterProjectId("") }] : []),
...(filterResourceId ? [{ label: `Resource filter active`, onRemove: () => setFilterResourceId("") }] : []),
...(filterStatus ? [{ label: `Status: ${filterStatus}`, onRemove: () => setFilterStatus("") }] : []),
...(hidePastProjects ? [{ label: "Hiding past projects", onRemove: () => setHidePastProjects(false) }] : []),
...(hideCompletedProjects ? [{ label: "Hiding completed/cancelled", onRemove: () => setHideCompletedProjects(false) }] : []),
...(hideDraftProjects ? [{ label: "Hiding draft projects", onRemove: () => setHideDraftProjects(false) }] : []),
];
function formatPeriod(alloc: AllocationWithDetails) {
return formatDate(alloc.startDate) + " \u2192 " + formatDate(alloc.endDate);
}
function handleSingleDelete(allocation: AllocationWithDetails) {
const id = getPlanningEntryMutationId(allocation);
if (!allocation.resourceId) {
deleteDemandMutation.mutate({ id });
return;
}
deleteAssignmentMutation.mutate({ id });
}
const singleDeletePending = deleteDemandMutation.isPending || deleteAssignmentMutation.isPending;
return (
<div className="p-6 pb-24">
{/* Page header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Allocations</h1>
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
{isLoading
? "Loading…"
: `${filteredAllocations.length} assignment${filteredAllocations.length !== 1 ? "s" : ""}${filteredDemands.length > 0 ? ` · ${filteredDemands.length} open demand${filteredDemands.length !== 1 ? "s" : ""}` : ""}`}
</p>
</div>
<div className="flex items-center gap-2">
<a
href="/api/reports/allocations"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors flex items-center gap-2"
>
PDF
</a>
<a
href="/api/reports/allocations?format=xlsx"
download
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors flex items-center gap-2"
>
XLS
</a>
<button
type="button"
onClick={openCreate}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
New Planning Entry
</button>
</div>
</div>
{/* Filters */}
<FilterBar>
<ProjectCombobox
value={filterProjectId || null}
onChange={(id) => setFilterProjectId(id ?? "")}
placeholder="Filter by project…"
className="min-w-[280px]"
/>
<ResourceCombobox
value={filterResourceId || null}
onChange={(id) => setFilterResourceId(id ?? "")}
placeholder="Filter by resource…"
className="min-w-[180px]"
/>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100"
>
<option value="">All Statuses</option>
{ALL_ALLOC_STATUSES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
<input
type="checkbox"
checked={hidePastProjects}
onChange={(e) => setHidePastProjects(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
Hide past
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
<input
type="checkbox"
checked={hideCompletedProjects}
onChange={(e) => setHideCompletedProjects(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
Hide completed
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
<input
type="checkbox"
checked={hideDraftProjects}
onChange={(e) => setHideDraftProjects(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
Hide drafts
</label>
<ColumnTogglePanel
allColumns={allColumns}
visibleKeys={visibleKeys}
onSetVisible={setVisible}
defaultKeys={defaultKeys}
/>
</FilterBar>
{/* Filter chips */}
{chips.length > 0 && (
<div className="mb-3">
<FilterChips chips={chips} onClearAll={clearAll} />
</div>
)}
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 w-10">
<input
type="checkbox"
checked={selection.isAllSelected(allocationIds)}
ref={(el) => {
if (el) el.indeterminate = selection.isIndeterminate(allocationIds);
}}
onChange={() => selection.toggleAll(allocationIds)}
className="rounded border-gray-300 dark:border-gray-600"
/>
</th>
{visibleColumns.map((col) => {
const tooltips: Record<string, { tip: string; width?: string }> = {
role: { tip: "The role this allocation was created for. May differ from the resource's primary role." },
hoursPerDay: { tip: "Planned working hours per calendar day for this allocation." },
cost: { tip: "Resource LCR × hours per day. Reflects the cost of one day of work for this allocation." },
status: { tip: "PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed.", width: "w-72" },
};
const t = tooltips[col.key];
const fieldMap: Record<string, string> = { dates: "startDate", hoursPerDay: "hoursPerDay", cost: "dailyCostCents" };
return (
<SortableColumnHeader
key={col.key}
label={col.label}
field={fieldMap[col.key] ?? col.key}
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
{...(t?.tip ? { tooltip: t.tip } : {})}
{...(t?.width ? { tooltipWidth: t.width } : {})}
/>
);
})}
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{isLoading && (
<tr>
<td colSpan={9} className="text-center py-12 text-gray-400 dark:text-gray-500 text-sm">Loading allocations</td>
</tr>
)}
{!isLoading && sorted.length === 0 && (
<tr>
<td colSpan={9} className="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No assignments found.</td>
</tr>
)}
{!isLoading &&
sorted.map((alloc) => {
const isSelected = selection.selectedIds.has(alloc.id);
return (
<tr key={alloc.id} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}>
<td className="px-4 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => selection.toggle(alloc.id)}
className="rounded border-gray-300 dark:border-gray-600"
/>
</td>
{visibleColumns.map((col) => {
switch (col.key) {
case "resource":
return <td key={col.key} className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">{alloc.resource?.displayName ?? "—"}</td>;
case "project":
return (
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{alloc.project ? (
<><span className="font-mono text-xs">{alloc.project.shortCode}</span> {alloc.project.name}</>
) : "—"}
</td>
);
case "role":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{alloc.role}</td>;
case "dates":
return <td key={col.key} className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatPeriod(alloc)}</td>;
case "hoursPerDay":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alloc.hoursPerDay}h</td>;
case "cost":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{(alloc.dailyCostCents / 100).toFixed(0)} </td>;
case "status":
return (
<td key={col.key} className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[alloc.status] ?? "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"}`}>
{alloc.status}
</span>
</td>
);
default:
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500"></td>;
}
})}
<td className="px-4 py-3">
<div className="flex items-center gap-2 justify-end">
<button type="button" onClick={() => openEdit(alloc)} className="text-xs text-blue-600 hover:text-blue-800 font-medium hover:underline">Edit</button>
<button
type="button"
onClick={() => setConfirmDelete({ single: alloc })}
disabled={singleDeletePending}
className="text-xs text-red-500 hover:text-red-700 font-medium hover:underline disabled:opacity-50"
>
Delete
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{!isLoading && filteredDemands.length > 0 && (
<div className="mt-6 bg-white dark:bg-gray-800 rounded-xl border border-amber-200 dark:border-amber-800/60 overflow-hidden">
<div className="px-4 py-3 border-b border-amber-200 dark:border-amber-800/60 bg-amber-50/70 dark:bg-amber-950/20 flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-200">Open Demands</h2>
<p className="text-xs text-amber-700 dark:text-amber-300/80">
Placeholder demand rows not yet assigned to a resource.
</p>
</div>
<span className="text-xs font-medium text-amber-700 dark:text-amber-300">
{filteredDemands.length} item{filteredDemands.length !== 1 ? "s" : ""}
</span>
</div>
<div className="divide-y divide-amber-100 dark:divide-amber-900/40">
{filteredDemands.map((demand) => (
<div
key={demand.id}
className="px-4 py-3 flex items-center justify-between gap-4 hover:bg-amber-50/40 dark:hover:bg-amber-950/10"
>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{demand.project ? (
<><span className="font-mono text-xs">{demand.project.shortCode}</span> {demand.project.name}</>
) : "Unknown project"}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{(demand.role ?? "Placeholder role")} · {formatPeriod(demand)} · {demand.hoursPerDay}h/day
</div>
</div>
<div className="flex items-center gap-4 flex-shrink-0">
<div className="text-right">
<div className="text-xs uppercase tracking-wide text-amber-700 dark:text-amber-300">Unfilled</div>
<div className="text-sm font-semibold text-amber-900 dark:text-amber-200">
{demand.unfilledHeadcount ?? demand.headcount} / {demand.requestedHeadcount ?? demand.headcount}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => openEdit(demand as AllocationWithDetails)}
className="text-xs text-blue-600 hover:text-blue-800 font-medium hover:underline"
>
Edit
</button>
<button
type="button"
onClick={() => setConfirmDelete({ single: demand as AllocationWithDetails })}
disabled={singleDeletePending}
className="text-xs text-red-500 hover:text-red-700 font-medium hover:underline disabled:opacity-50"
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Batch Status Picker */}
{batchStatusPicker && (
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl p-5 min-w-[220px]" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Set status for {selection.count} allocations</h3>
<div className="flex flex-col gap-1">
{ALL_ALLOC_STATUSES.map((s) => (
<button
key={s.value}
type="button"
onClick={() => {
setConfirmBatchStatus({ ids: selectedMutationIds, status: s.value });
setBatchStatusPicker(false);
}}
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[s.value]}`}>
{s.label}
</span>
</button>
))}
</div>
</div>
</div>
)}
{/* Confirm single delete */}
{confirmDelete?.single && (
<ConfirmDialog
title="Delete Allocation"
message={`Delete allocation for ${confirmDelete.single.resource?.displayName ?? "resource"} on ${confirmDelete.single.project?.name ?? "project"}?`}
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
handleSingleDelete(confirmDelete.single!);
setConfirmDelete(null);
}}
onCancel={() => setConfirmDelete(null)}
/>
)}
{/* Confirm batch delete */}
{confirmDelete?.ids && (
<ConfirmDialog
title="Delete Allocations"
message={`Delete ${confirmDelete.ids.length} selected allocation${confirmDelete.ids.length !== 1 ? "s" : ""}? This cannot be undone.`}
confirmLabel="Delete All"
variant="danger"
onConfirm={() => {
batchDeleteMutation.mutate({ ids: confirmDelete.ids! });
setConfirmDelete(null);
}}
onCancel={() => setConfirmDelete(null)}
/>
)}
{/* Confirm batch status */}
{confirmBatchStatus && (
<ConfirmDialog
title="Update Allocation Status"
message={`Set ${confirmBatchStatus.ids.length} allocation${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`}
confirmLabel="Update"
onConfirm={() => {
batchStatusMutation.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never });
setConfirmBatchStatus(null);
}}
onCancel={() => setConfirmBatchStatus(null)}
/>
)}
{/* Batch Action Bar */}
<BatchActionBar
count={selection.count}
onClear={selection.clear}
actions={[
{
label: "Set Status…",
onClick: () => setBatchStatusPicker(true),
disabled: batchStatusMutation.isPending,
},
{
label: `Delete (${selection.count})`,
variant: "danger",
onClick: () => setConfirmDelete({ ids: selectedMutationIds }),
disabled: batchDeleteMutation.isPending,
},
]}
/>
{/* Modal */}
{modalOpen && (
<AllocationModal allocation={editingAllocation} onClose={closeModal} onSuccess={closeModal} />
)}
</div>
);
}
@@ -0,0 +1,186 @@
"use client";
import { useRef, useState } from "react";
import { AllocationStatus } from "@planarchy/shared";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { trpc } from "~/lib/trpc/client.js";
interface OpenDemandAllocation {
id: string;
entityId?: string | null;
sourceAllocationId?: string | null;
projectId: string;
roleId: string | null;
role: string | null;
headcount: number;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
roleEntity?: { id: string; name: string; color: string | null } | null;
project?: { id: string; name: string; shortCode: string };
}
interface FillOpenDemandModalProps {
allocation: OpenDemandAllocation;
onClose: () => void;
onSuccess: () => void;
}
function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-GB", { year: "numeric", month: "short", day: "numeric" });
}
export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpenDemandModalProps) {
const [resourceId, setResourceId] = useState("");
const [hoursPerDay, setHoursPerDay] = useState<number>(allocation.hoursPerDay);
const [search, setSearch] = useState("");
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils();
const invalidatePlanningViews = async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
await utils.timeline.getEntries.invalidate();
await utils.timeline.getEntriesView.invalidate();
await utils.timeline.getProjectContext.invalidate();
await utils.timeline.getBudgetStatus.invalidate();
};
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, search: search || undefined, limit: 100 },
{ staleTime: 15_000 },
) as { data: { resources: Array<{ id: string; displayName: string; eid: string }> } | undefined };
const fillOpenDemandMutation = trpc.allocation.fillOpenDemandByAllocation.useMutation({
onSuccess: async () => {
await invalidatePlanningViews();
onSuccess();
},
onError: (err) => setServerError(err.message),
});
const roleName = allocation.roleEntity?.name ?? allocation.role ?? "Unknown Role";
const roleColor = allocation.roleEntity?.color ?? "#6366f1";
const resourceList = resources?.resources ?? [];
const isPending = fillOpenDemandMutation.isPending;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!resourceId) {
setServerError("Please select a resource.");
return;
}
fillOpenDemandMutation.mutate({
allocationId: getPlanningEntryMutationId(allocation),
resourceId,
hoursPerDay,
status: AllocationStatus.PROPOSED,
});
}
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-md mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Assign Open Demand</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none">&times;</button>
</div>
<div className="px-6 pt-4 pb-2">
{/* Demand summary */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 mb-4 flex items-start gap-3">
<div className="w-3 h-3 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: roleColor }} />
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{allocation.project?.name} · {formatDate(allocation.startDate)} {formatDate(allocation.endDate)}
</div>
{allocation.headcount > 1 && (
<div className="text-xs text-amber-600 mt-0.5">
{allocation.headcount} slots remaining assigning one resource
</div>
)}
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="px-6 pb-5 space-y-4">
{/* Resource search */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Search Resource
</label>
<input
type="text"
placeholder="Search by name or EID…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Assign Resource <span className="text-red-500">*</span>
</label>
<select
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100"
required
size={Math.min(6, Math.max(3, resourceList.length))}
>
<option value="">Select a resource</option>
{resourceList.map((r) => (
<option key={r.id} value={r.id}>
{r.displayName} ({r.eid})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hours / Day</label>
<input
type="number"
value={hoursPerDay}
onChange={(e) => setHoursPerDay(Number(e.target.value))}
min={0.5}
max={8}
step={0.5}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100"
/>
</div>
{serverError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{serverError}
</div>
)}
<div className="flex items-center justify-end gap-3 pt-2">
<button type="button" onClick={onClose} disabled={isPending} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50">
Cancel
</button>
<button type="submit" disabled={isPending || !resourceId} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50">
{isPending ? "Assigning…" : "Create Assignment"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,202 @@
"use client";
import { RecurrenceFrequency } from "@planarchy/shared";
import type { RecurrencePattern } from "@planarchy/shared";
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
interface RecurrenceEditorProps {
value: RecurrencePattern | undefined;
onChange: (pattern: RecurrencePattern | undefined) => void;
}
export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
const freq = value?.frequency ?? RecurrenceFrequency.WEEKLY;
function update(patch: Partial<RecurrencePattern>) {
onChange({ ...value, frequency: freq, ...patch });
}
function setFrequency(f: RecurrenceFrequency) {
// Reset pattern-specific fields when switching frequency
onChange({ frequency: f });
}
function toggleWeekday(dow: number) {
const current = value?.weekdays ?? [];
const next = current.includes(dow)
? current.filter((d) => d !== dow)
: [...current, dow].sort((a, b) => a - b);
update({ weekdays: next });
}
const inputClass =
"px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100";
const labelClass = "text-xs font-medium text-gray-600 dark:text-gray-400 block mb-1";
return (
<div className="space-y-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
{/* Frequency selector */}
<div>
<span className={labelClass}>Frequency</span>
<div className="flex gap-2 flex-wrap">
{Object.values(RecurrenceFrequency).map((f) => (
<button
key={f}
type="button"
onClick={() => setFrequency(f)}
className={`px-3 py-1 text-xs rounded-full border transition-colors ${
freq === f
? "bg-brand-600 text-white border-brand-600"
: "border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-brand-400"
}`}
>
{f === RecurrenceFrequency.WEEKLY
? "Weekly"
: f === RecurrenceFrequency.BIWEEKLY
? "Biweekly"
: f === RecurrenceFrequency.MONTHLY
? "Monthly"
: "Custom"}
</button>
))}
</div>
</div>
{/* Weekday picker — WEEKLY and BIWEEKLY */}
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
<div>
<span className={labelClass}>Days of week</span>
<div className="flex gap-1">
{WEEKDAY_LABELS.map((label, dow) => {
const selected = (value?.weekdays ?? []).includes(dow);
return (
<button
key={dow}
type="button"
onClick={() => toggleWeekday(dow)}
className={`w-9 h-9 text-xs rounded-full border font-medium transition-colors ${
selected
? "bg-brand-600 text-white border-brand-600"
: "border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-brand-400"
}`}
>
{label}
</button>
);
})}
</div>
</div>
)}
{/* Biweekly interval */}
{freq === RecurrenceFrequency.BIWEEKLY && (
<div>
<label className={labelClass}>Every N weeks</label>
<input
type="number"
min={2}
max={8}
value={value?.interval ?? 2}
onChange={(e) => update({ interval: Number(e.target.value) })}
className={`${inputClass} w-24`}
/>
</div>
)}
{/* Monthly — day of month */}
{freq === RecurrenceFrequency.MONTHLY && (
<div>
<label className={labelClass}>Day of month (131)</label>
<input
type="number"
min={1}
max={31}
value={value?.monthDay ?? 1}
onChange={(e) => update({ monthDay: Number(e.target.value) })}
className={`${inputClass} w-24`}
/>
</div>
)}
{/* Custom — hoursPerDay override */}
{freq === RecurrenceFrequency.CUSTOM && (
<div>
<label className={labelClass}>Hours per active day</label>
<input
type="number"
min={0.5}
max={24}
step={0.5}
value={value?.hoursPerDay ?? 8}
onChange={(e) => update({ hoursPerDay: Number(e.target.value) })}
className={`${inputClass} w-24`}
/>
</div>
)}
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
{freq !== RecurrenceFrequency.CUSTOM && (
<div>
<label className={labelClass}>Hours per recurring day (optional override)</label>
<input
type="number"
min={0.5}
max={24}
step={0.5}
placeholder="Use allocation default"
value={value?.hoursPerDay ?? ""}
onChange={(e) => {
const next = { ...value, frequency: freq } as RecurrencePattern;
if (e.target.value === "") {
delete next.hoursPerDay;
} else {
next.hoursPerDay = Number(e.target.value);
}
onChange(next);
}}
className={`${inputClass} w-40`}
/>
</div>
)}
{/* Optional date range overrides */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>Recurrence start (optional)</label>
<input
type="date"
value={value?.startDate ?? ""}
onChange={(e) => {
const next = { ...value, frequency: freq } as RecurrencePattern;
if (e.target.value) {
next.startDate = e.target.value;
} else {
delete next.startDate;
}
onChange(next);
}}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Recurrence end (optional)</label>
<input
type="date"
value={value?.endDate ?? ""}
onChange={(e) => {
const next = { ...value, frequency: freq } as RecurrencePattern;
if (e.target.value) {
next.endDate = e.target.value;
} else {
delete next.endDate;
}
onChange(next);
}}
className={inputClass}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,515 @@
"use client";
import { useState, useId } from "react";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import { trpc } from "~/lib/trpc/client.js";
import * as XLSX from "xlsx";
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
// SVG fill colors for the bar chart (work in both light and dark contexts)
const PROFICIENCY_SVG_COLORS = ["#9ca3af", "#60a5fa", "#818cf8", "#f59e0b", "#4ade80"];
// Tailwind class sets per proficiency level (15), dark-mode aware
const PROFICIENCY_CLASSES = [
"bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500",
"bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/60 dark:text-blue-200 dark:border-blue-600",
"bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-900/60 dark:text-indigo-200 dark:border-indigo-500",
"bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/60 dark:text-amber-200 dark:border-amber-500",
"bg-green-100 text-green-800 border-green-300 dark:bg-green-900/60 dark:text-green-200 dark:border-green-500",
];
function proficiencyClasses(level: number): string {
const idx = Math.max(0, Math.min(4, Math.round(level) - 1));
return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!;
}
function ProficiencyBadge({ value }: { value: number }) {
return (
<span className={`inline-block px-2 py-0.5 text-xs rounded font-medium border ${proficiencyClasses(value)}`}>
{value} {PROFICIENCY_LABELS[value] ?? ""}
</span>
);
}
type SkillRule = { skill: string; minProficiency: number };
export function SkillsAnalytics() {
const datalistId = useId();
// ── Skill table filters ──────────────────────────────────────────────────
const [categoryFilter, setCategoryFilter] = useState<string>("");
const [minCount, setMinCount] = useState<number>(1);
const [skillSearch, setSkillSearch] = useState<string>("");
// ── People Finder ────────────────────────────────────────────────────────
const [rules, setRules] = useState<SkillRule[]>([]);
const [operator, setOperator] = useState<"AND" | "OR">("AND");
const [peopleChapter, setPeopleChapter] = useState<string>("");
const { data, isLoading, error } = trpc.resource.getSkillsAnalytics.useQuery(undefined, {
staleTime: 60_000,
});
const activeRules = rules.filter((r) => r.skill.trim().length > 0);
const { data: peopleResults, isFetching: peopleFetching } =
trpc.resource.searchBySkills.useQuery(
{
rules: activeRules,
operator,
...(peopleChapter ? { chapter: peopleChapter } : {}),
},
{
enabled: activeRules.length > 0,
staleTime: 30_000,
},
);
function addRule() {
setRules((prev) => [...prev, { skill: "", minProficiency: 1 }]);
}
function removeRule(idx: number) {
setRules((prev) => prev.filter((_, i) => i !== idx));
}
function updateRule(idx: number, patch: Partial<SkillRule>) {
setRules((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
}
function exportXlsx() {
if (!data) return;
const rows = data.aggregated.map((e) => ({
Skill: e.skill,
Category: e.category,
"# Resources": e.count,
"Avg Proficiency": e.avgProficiency,
Chapters: e.chapters.join(", "),
}));
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Skills");
XLSX.writeFile(wb, `skills-analytics-${Date.now()}.xlsx`);
}
const allSkillNames = (data?.aggregated ?? []).map((e) => e.skill);
const filtered = (data?.aggregated ?? []).filter((e) => {
if (categoryFilter && e.category !== categoryFilter) return false;
if (e.count < minCount) return false;
if (skillSearch && !e.skill.toLowerCase().includes(skillSearch.toLowerCase())) return false;
return true;
});
const { sorted: sortedSkills, sortField: skillSortField, sortDir: skillSortDir, toggle: skillToggle } = useTableSort(filtered);
const top20 = filtered.slice(0, 20);
const gapSkills = (data?.aggregated ?? []).filter((e) => e.count < 3 && e.avgProficiency >= 3);
if (isLoading) {
return (
<div className="p-6 animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-64" />
<div className="h-64 bg-gray-100 rounded-xl" />
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
{error.message}
</div>
</div>
);
}
return (
<div className="p-6 pb-24 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Skills Analytics</h1>
<p className="text-sm text-gray-500 mt-1">
{data?.totalResources} active resources · {data?.totalSkillEntries} distinct skills
</p>
</div>
<button
type="button"
onClick={exportXlsx}
disabled={!data}
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
Export XLS
</button>
</div>
{/* ── People Finder ──────────────────────────────────────────────────── */}
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-800">People Finder</h2>
<span className="text-xs text-gray-400">
Find resources that match skill criteria
</span>
</div>
{/* Rules */}
<datalist id={datalistId}>
{allSkillNames.map((s) => (
<option key={s} value={s} />
))}
</datalist>
<div className="space-y-2">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2 flex-wrap">
{/* AND / OR connector label */}
{idx > 0 && (
<button
type="button"
onClick={() => setOperator((op) => (op === "AND" ? "OR" : "AND"))}
className="w-12 text-center text-xs font-bold px-2 py-1.5 rounded-lg border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 transition-colors shrink-0"
>
{operator}
</button>
)}
{idx === 0 && (
<span className="w-12 text-center text-xs font-medium text-gray-400 shrink-0">
knows
</span>
)}
{/* Skill input */}
<input
type="text"
list={datalistId}
placeholder="Skill name…"
value={rule.skill}
onChange={(e) => updateRule(idx, { skill: e.target.value })}
className="flex-1 min-w-40 px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
{/* Min proficiency selector */}
<div className="flex items-center gap-1">
<span className="text-xs text-gray-400 shrink-0">min.</span>
<div className="flex rounded-lg overflow-hidden border border-gray-200">
{[1, 2, 3, 4, 5].map((lvl) => (
<button
key={lvl}
type="button"
title={PROFICIENCY_LABELS[lvl]}
onClick={() => updateRule(idx, { minProficiency: lvl })}
className={`px-2 py-1 text-xs font-medium transition-colors ${
rule.minProficiency === lvl
? "bg-brand-600 text-white"
: "bg-white text-gray-500 hover:bg-gray-50"
}`}
>
{lvl}
</button>
))}
</div>
</div>
{/* Remove */}
<button
type="button"
onClick={() => removeRule(idx)}
className="text-gray-400 hover:text-red-500 transition-colors p-1"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
{/* Add rule + chapter filter row */}
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={addRule}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-dashed border-brand-300 text-brand-600 hover:bg-brand-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add skill rule
</button>
{rules.length > 1 && (
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500">Match:</span>
<button
type="button"
onClick={() => setOperator("AND")}
className={`px-2.5 py-1 text-xs font-medium rounded-l-lg border transition-colors ${
operator === "AND"
? "bg-brand-600 border-brand-600 text-white"
: "bg-white border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
>
All (AND)
</button>
<button
type="button"
onClick={() => setOperator("OR")}
className={`px-2.5 py-1 text-xs font-medium rounded-r-lg border -ml-px transition-colors ${
operator === "OR"
? "bg-brand-600 border-brand-600 text-white"
: "bg-white border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
>
Any (OR)
</button>
</div>
)}
{(data?.allChapters ?? []).length > 0 && (
<div className="flex items-center gap-1.5 ml-auto">
<span className="text-xs text-gray-500">Chapter:</span>
<select
value={peopleChapter}
onChange={(e) => setPeopleChapter(e.target.value)}
className="px-2 py-1.5 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">All chapters</option>
{(data?.allChapters ?? []).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
)}
</div>
{/* Results */}
{activeRules.length > 0 && (
<div className="border-t border-gray-100 pt-4">
{peopleFetching ? (
<div className="text-sm text-gray-400 animate-pulse">Searching</div>
) : peopleResults && peopleResults.length === 0 ? (
<p className="text-sm text-gray-400 italic">No resources match these criteria.</p>
) : peopleResults && peopleResults.length > 0 ? (
<>
<p className="text-xs text-gray-500 mb-3">
{peopleResults.length} resource{peopleResults.length !== 1 ? "s" : ""} found
</p>
<div className="space-y-2">
{peopleResults.map((person) => (
<div
key={person.id}
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/resources/${person.id}`}
className="text-sm font-medium text-gray-900 hover:text-brand-600 transition-colors"
>
{person.displayName}
</a>
{person.chapter && (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-100">
{person.chapter}
</span>
)}
</div>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{person.matchedSkills.map((s) => (
<span
key={s.skill}
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border ${proficiencyClasses(s.proficiency)}`}
>
{s.skill}
<span className="font-semibold">{s.proficiency}</span>
</span>
))}
</div>
</div>
<a
href={`/resources/${person.id}`}
className="text-xs text-brand-600 hover:underline shrink-0 mt-0.5"
>
View
</a>
</div>
))}
</div>
</>
) : null}
</div>
)}
</div>
{/* ── Skill table filters ────────────────────────────────────────────── */}
<div className="flex flex-wrap gap-3 items-center">
{/* Fuzzy search */}
<div className="relative">
<svg
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
</svg>
<input
type="text"
placeholder="Search skills…"
value={skillSearch}
onChange={(e) => setSkillSearch(e.target.value)}
className="pl-8 pr-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500 w-52"
/>
</div>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">All Categories</option>
{(data?.categories ?? []).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<label className="flex items-center gap-2 text-sm text-gray-600">
Min. resources:
<input
type="number"
min={1}
max={50}
value={minCount}
onChange={(e) => setMinCount(Math.max(1, parseInt(e.target.value, 10) || 1))}
className="w-16 px-2 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</label>
<span className="text-sm text-gray-400">{filtered.length} skills shown</span>
{(skillSearch || categoryFilter || minCount > 1) && (
<button
type="button"
onClick={() => { setSkillSearch(""); setCategoryFilter(""); setMinCount(1); }}
className="text-xs text-brand-600 hover:underline"
>
Clear filters
</button>
)}
</div>
{/* Top 20 Skills Bar Chart */}
{top20.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-4">Top Skills by Resource Count</h2>
<ResponsiveContainer width="100%" height={320}>
<BarChart data={top20} layout="vertical" margin={{ left: 160, right: 20, top: 0, bottom: 0 }}>
<XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="skill" tick={{ fontSize: 11 }} width={155} />
<Tooltip
formatter={(value: number | undefined) => [`${value ?? 0} resources`, "Count"] as [string, string]}
contentStyle={{ fontSize: 12, borderRadius: 8 }}
/>
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
{top20.map((entry) => (
<Cell key={entry.skill} fill={PROFICIENCY_SVG_COLORS[Math.max(0, Math.min(4, Math.round(entry.avgProficiency) - 1))] ?? "#6b7280"} strokeWidth={0} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<p className="text-xs text-gray-400 mt-2">Bar color = average proficiency (light dark = low high)</p>
</div>
)}
{/* Skills Gap */}
{gapSkills.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3">
Skills Gap
<span className="ml-2 text-xs font-normal text-gray-400">high proficiency, few practitioners (&lt;3)</span>
</h2>
<div className="flex flex-wrap gap-2">
{gapSkills.map((e) => (
<button
key={e.skill}
type="button"
onClick={() => {
setRules((prev) => [...prev, { skill: e.skill, minProficiency: 3 }]);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
title="Add to People Finder"
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm rounded-full bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 transition-colors"
>
{e.skill}
<span className="text-xs opacity-70">{e.count} person{e.count !== 1 ? "s" : ""}</span>
</button>
))}
</div>
<p className="text-xs text-gray-400 mt-2">Click a skill to add it to the People Finder above.</p>
</div>
)}
{/* Skills Table */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<SortableColumnHeader label="Skill" field="skill" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} />
<SortableColumnHeader label="Category" field="category" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} />
<SortableColumnHeader label="Resources" field="count" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} align="right" />
<SortableColumnHeader label="Avg Prof." field="avgProficiency" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} align="right" />
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chapters</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Find</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sortedSkills.map((e) => (
<tr key={e.skill} className="hover:bg-gray-50">
<td className="px-4 py-2.5 font-medium text-gray-900">{e.skill}</td>
<td className="px-4 py-2.5 text-gray-500">{e.category}</td>
<td className="px-4 py-2.5 text-right text-gray-700">{e.count}</td>
<td className="px-4 py-2.5 text-right">
<ProficiencyBadge value={e.avgProficiency} />
</td>
<td className="px-4 py-2.5 text-gray-400 text-xs">{e.chapters.join(", ") || "—"}</td>
<td className="px-4 py-2.5 text-center">
<button
type="button"
title="Add to People Finder"
onClick={() => {
setRules((prev) => [...prev, { skill: e.skill, minProficiency: 1 }]);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
className="text-gray-400 hover:text-brand-600 transition-colors"
>
<svg className="w-4 h-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</td>
</tr>
))}
{sortedSkills.length === 0 && (
<tr>
<td colSpan={6} className="text-center py-10 text-gray-400 text-sm">
No skills found matching the filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,502 @@
"use client";
import { useState } from "react";
import { FieldType } from "@planarchy/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { RolePresetsEditor } from "./RolePresetsEditor.js";
const FIELD_TYPES: { value: FieldType; label: string }[] = [
{ value: FieldType.TEXT, label: "Text" },
{ value: FieldType.TEXTAREA, label: "Textarea" },
{ value: FieldType.NUMBER, label: "Number" },
{ value: FieldType.BOOLEAN, label: "Boolean" },
{ value: FieldType.DATE, label: "Date" },
{ value: FieldType.SELECT, label: "Select" },
{ value: FieldType.MULTI_SELECT, label: "Multi-Select" },
{ value: FieldType.URL, label: "URL" },
{ value: FieldType.EMAIL, label: "Email" },
];
const INPUT_CLS =
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
const BTN_PRIMARY =
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
const BTN_SECONDARY =
"px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium";
const BTN_DANGER =
"px-2 py-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded text-sm transition-colors";
function makeEmptyField(order: number): BlueprintFieldDefinition {
return {
id: Math.random().toString(36).slice(2),
key: "",
label: "",
type: FieldType.TEXT,
required: false,
order,
};
}
// ---------------------------------------------------------------------------
// OptionsEditor — for SELECT / MULTI_SELECT
// ---------------------------------------------------------------------------
interface OptionsEditorProps {
options: FieldOption[];
onChange: (options: FieldOption[]) => void;
}
function OptionsEditor({ options, onChange }: OptionsEditorProps) {
function addOption() {
onChange([...options, { value: "", label: "" }]);
}
function removeOption(idx: number) {
onChange(options.filter((_, i) => i !== idx));
}
function updateOption(idx: number, field: "value" | "label", val: string) {
const next = options.map((o, i) =>
i === idx ? { ...o, [field]: val } : o,
);
onChange(next);
}
return (
<div className="mt-2 space-y-1.5">
<p className="text-xs font-medium text-gray-600">Options</p>
{options.map((opt, idx) => (
<div key={idx} className="flex items-center gap-2">
<input
type="text"
value={opt.value}
onChange={(e) => updateOption(idx, "value", e.target.value)}
placeholder="value"
className={`${INPUT_CLS} flex-1`}
/>
<input
type="text"
value={opt.label}
onChange={(e) => updateOption(idx, "label", e.target.value)}
placeholder="label"
className={`${INPUT_CLS} flex-1`}
/>
<button
type="button"
onClick={() => removeOption(idx)}
className={BTN_DANGER}
aria-label="Remove option"
>
×
</button>
</div>
))}
<button
type="button"
onClick={addOption}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
+ Add option
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// FieldRow — a single field definition row
// ---------------------------------------------------------------------------
interface FieldRowProps {
field: BlueprintFieldDefinition;
onChange: (field: BlueprintFieldDefinition) => void;
onDelete: () => void;
}
function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
const [expanded, setExpanded] = useState(false);
const needsOptions =
field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
function update<K extends keyof BlueprintFieldDefinition>(
key: K,
value: BlueprintFieldDefinition[K],
) {
onChange({ ...field, [key]: value });
}
return (
<div className="border border-gray-200 rounded-lg p-3 bg-white">
{/* Main row */}
<div className="flex flex-wrap items-center gap-2">
{/* Drag handle placeholder */}
<span className="text-gray-300 cursor-grab select-none text-lg leading-none">
</span>
{/* Key */}
<input
type="text"
value={field.key}
onChange={(e) => update("key", e.target.value)}
placeholder="field_key"
className={`${INPUT_CLS} w-36 font-mono`}
aria-label="Field key"
/>
{/* Label */}
<input
type="text"
value={field.label}
onChange={(e) => update("label", e.target.value)}
placeholder="Label"
className={`${INPUT_CLS} w-40`}
aria-label="Field label"
/>
{/* Type */}
<select
value={field.type}
onChange={(e) => {
const t = e.target.value as FieldType;
// Clear options when switching away from select types
const clearedOptions =
t === FieldType.SELECT || t === FieldType.MULTI_SELECT
? field.options ?? []
: undefined;
onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition);
}}
className={`${INPUT_CLS} w-36`}
aria-label="Field type"
>
{FIELD_TYPES.map((ft) => (
<option key={ft.value} value={ft.value}>
{ft.label}
</option>
))}
</select>
{/* Required */}
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none whitespace-nowrap">
<input
type="checkbox"
checked={field.required}
onChange={(e) => update("required", e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Req.
</label>
{/* Expand/Collapse optional fields */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="text-xs text-gray-400 hover:text-gray-600 ml-auto whitespace-nowrap"
aria-label={expanded ? "Collapse options" : "Expand options"}
>
{expanded ? "▲ less" : "▼ more"}
</button>
{/* Delete */}
<button
type="button"
onClick={onDelete}
className={BTN_DANGER}
aria-label="Delete field"
>
×
</button>
</div>
{/* Expanded optional fields */}
{expanded && (
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">Group</label>
<input
type="text"
value={field.group ?? ""}
onChange={(e) => update("group", e.target.value || undefined)}
placeholder="Section heading"
className={INPUT_CLS}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">
Placeholder
</label>
<input
type="text"
value={field.placeholder ?? ""}
onChange={(e) =>
update("placeholder", e.target.value || undefined)
}
placeholder="Placeholder text"
className={INPUT_CLS}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">
Description
</label>
<input
type="text"
value={field.description ?? ""}
onChange={(e) =>
update("description", e.target.value || undefined)
}
placeholder="Helper text"
className={INPUT_CLS}
/>
</div>
<div className="flex items-center gap-4 col-span-full pt-1">
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none">
<input
type="checkbox"
checked={field.showInList ?? false}
onChange={(e) => update("showInList", e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Show in list view
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none">
<input
type="checkbox"
checked={field.isFilterable ?? false}
onChange={(e) => update("isFilterable", e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Filterable
</label>
</div>
{needsOptions && (
<div className="col-span-full">
<OptionsEditor
options={field.options ?? []}
onChange={(opts) => update("options", opts)}
/>
</div>
)}
</div>
)}
{/* Options inline hint when collapsed */}
{!expanded && needsOptions && (field.options?.length ?? 0) === 0 && (
<p className="mt-1 text-xs text-amber-600">
No options defined yet click more to add them.
</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// BlueprintFieldEditor — the modal
// ---------------------------------------------------------------------------
interface BlueprintFieldEditorProps {
blueprintId: string;
blueprintName: string;
initialFieldDefs: BlueprintFieldDefinition[];
initialRolePresets?: StaffingRequirement[];
initialTab?: "fields" | "presets";
onClose: () => void;
}
export function BlueprintFieldEditor({
blueprintId,
blueprintName,
initialFieldDefs,
initialRolePresets = [],
initialTab = "fields",
onClose,
}: BlueprintFieldEditorProps) {
const utils = trpc.useUtils();
const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab);
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(
() =>
[...initialFieldDefs].sort((a, b) => a.order - b.order),
);
const [saveError, setSaveError] = useState<string | null>(null);
const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
const updateMutation = trpc.blueprint.update.useMutation();
const presetMutation = trpc.blueprint.updateRolePresets.useMutation();
function addField() {
setFields((prev) => [...prev, makeEmptyField(prev.length)]);
}
function removeField(idx: number) {
setFields((prev) =>
prev
.filter((_, i) => i !== idx)
.map((f, i) => ({ ...f, order: i })),
);
}
function updateField(idx: number, updated: BlueprintFieldDefinition) {
setFields((prev) =>
prev.map((f, i) => (i === idx ? updated : f)),
);
}
function handleSave() {
setSaveError(null);
// Reassign order by current list position
const normalised = fields.map((f, i) => ({ ...f, order: i }));
updateMutation.mutate(
{
id: blueprintId,
data: { fieldDefs: normalised },
},
{
onSuccess: async () => {
await utils.blueprint.list.invalidate();
onClose();
},
onError: (err) => {
setSaveError(err.message);
},
},
);
}
// Close on backdrop click
function handleBackdropClick(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === e.currentTarget) onClose();
}
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={handleBackdropClick}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
Edit Fields:{" "}
<span className="text-gray-600 font-normal">{blueprintName}</span>
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
aria-label="Close"
>
×
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200 px-6">
{(["fields", "presets"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === tab
? "border-brand-500 text-brand-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{tab === "fields" ? "Fields" : "Role Presets"}
</button>
))}
</div>
{activeTab === "fields" ? (
<>
{/* Field list */}
<div className="px-6 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
{fields.length === 0 && (
<p className="text-sm text-gray-400 text-center py-8">
No fields yet. Click &ldquo;+ Add Field&rdquo; to get started.
</p>
)}
{fields.map((field, idx) => (
<FieldRow
key={field.id}
field={field}
onChange={(updated) => updateField(idx, updated)}
onDelete={() => removeField(idx)}
/>
))}
</div>
{/* Add field button */}
<div className="px-6 pb-2">
<button
type="button"
onClick={addField}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium"
>
<span className="text-lg leading-none">+</span> Add Field
</button>
</div>
{/* Error */}
{saveError && (
<div className="mx-6 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{saveError}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={updateMutation.isPending}
className={BTN_PRIMARY}
>
{updateMutation.isPending ? "Saving…" : "Save Fields"}
</button>
</div>
</>
) : (
<div className="px-6 py-4">
<p className="text-xs text-gray-500 mb-4">
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this blueprint is selected.
</p>
<RolePresetsEditor
initialPresets={initialRolePresets}
onSave={(presets) =>
presetMutation.mutate(
{ id: blueprintId, rolePresets: presets },
{
onSuccess: async () => {
await utils.blueprint.list.invalidate();
setPresetSaveError(null);
onClose();
},
onError: (err) => {
setPresetSaveError(err.message);
},
},
)
}
isSaving={presetMutation.isPending}
saveError={presetSaveError}
/>
<div className="flex justify-start mt-4 border-t border-gray-200 pt-4">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
Close
</button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,510 @@
"use client";
import { useState, useEffect } from "react";
import type { FormEvent, MouseEvent } from "react";
import { BlueprintTarget } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { BlueprintFieldEditor } from "./BlueprintFieldEditor.js";
import { useSelection } from "~/hooks/useSelection.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { FilterBar } from "~/components/ui/FilterBar.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
const INPUT_CLS =
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
const BTN_PRIMARY =
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
const BTN_SECONDARY =
"px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium";
interface NewBlueprintModalProps {
onClose: () => void;
onCreated: () => void;
}
type BlueprintTargetValue = "RESOURCE" | "PROJECT";
type BlueprintSortField = "name" | "target" | "fieldCount" | "presetCount" | "global";
function NewBlueprintModal({ onClose, onCreated }: NewBlueprintModalProps) {
const utils = trpc.useUtils();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [target, setTarget] = useState<BlueprintTargetValue>("RESOURCE");
const [error, setError] = useState<string | null>(null);
const createMutation = trpc.blueprint.create.useMutation();
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("Name is required.");
return;
}
try {
await createMutation.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
target: target as BlueprintTarget,
fieldDefs: [],
defaults: {},
validationRules: [],
});
await utils.blueprint.list.invalidate();
onCreated();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create blueprint.");
}
}
function handleBackdropClick(e: MouseEvent<HTMLDivElement>) {
if (e.target === e.currentTarget) onClose();
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8" onClick={handleBackdropClick}>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">New Blueprint</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none" aria-label="Close">×</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Name <span className="text-red-500">*</span></label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Resource Extended Fields" className={INPUT_CLS} autoFocus />
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional description" className={`${INPUT_CLS} resize-none`} rows={2} />
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Target</label>
<select value={target} onChange={(e) => setTarget(e.target.value as BlueprintTargetValue)} className={INPUT_CLS}>
<option value="RESOURCE">Resource</option>
<option value="PROJECT">Project</option>
</select>
</div>
{error && <p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</p>}
<div className="flex items-center justify-end gap-3 pt-2">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>Cancel</button>
<button type="submit" disabled={createMutation.isPending} className={BTN_PRIMARY}>
{createMutation.isPending ? "Creating…" : "Create Blueprint"}
</button>
</div>
</form>
</div>
</div>
);
}
interface BlueprintRow {
id: string;
name: string;
description: string | null;
target: BlueprintTargetValue;
fieldDefs: unknown;
rolePresets: unknown;
isGlobal?: boolean;
}
interface BlueprintCardProps {
blueprint: BlueprintRow;
onEditFields: () => void;
onEditStaffing: () => void;
onToggleGlobal: () => void;
onDelete: () => void;
isSelected: boolean;
onToggleSelect: () => void;
}
function BlueprintCard({
blueprint,
onEditFields,
onEditStaffing,
onToggleGlobal,
onDelete,
isSelected,
onToggleSelect,
}: BlueprintCardProps) {
const fieldDefs = Array.isArray(blueprint.fieldDefs) ? (blueprint.fieldDefs as BlueprintFieldDefinition[]) : [];
const rolePresets = Array.isArray(blueprint.rolePresets) ? (blueprint.rolePresets as unknown[]) : [];
const fieldCount = fieldDefs.length;
const presetCount = rolePresets.length;
const isProject = blueprint.target === "PROJECT";
return (
<div className={`bg-white rounded-xl border p-5 flex flex-col gap-3 hover:shadow-sm transition-shadow ${isSelected ? "border-brand-400 bg-brand-50" : "border-gray-200"}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
<input
type="checkbox"
checked={isSelected}
onChange={onToggleSelect}
className="mt-0.5 rounded border-gray-300"
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{blueprint.name}</h3>
{blueprint.description && (
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{blueprint.description}</p>
)}
</div>
</div>
<span className={`shrink-0 inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700" : "bg-blue-50 text-blue-700"}`}>
{blueprint.target}
</span>
</div>
<div className="flex flex-wrap gap-3 text-sm text-gray-500">
<span>{fieldCount === 0 ? "No fields" : `${fieldCount} field${fieldCount === 1 ? "" : "s"}`}</span>
{isProject && (
<span className={presetCount > 0 ? "text-brand-600 font-medium" : ""}>
{presetCount === 0 ? "No staffing presets" : `${presetCount} staffing preset${presetCount === 1 ? "" : "s"}`}
</span>
)}
{blueprint.isGlobal && (
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 font-medium">Global</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-gray-100">
<button type="button" onClick={onEditFields} className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors">
Edit Fields
</button>
{isProject && (
<button type="button" onClick={onEditStaffing} className="px-3 py-1.5 border border-brand-300 text-brand-700 rounded-lg hover:bg-brand-50 text-sm font-medium transition-colors">
Edit Staffing Presets
</button>
)}
<button
type="button"
onClick={onToggleGlobal}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 ${
blueprint.isGlobal
? "border border-amber-300 text-amber-700 hover:bg-amber-50"
: "border border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
title={blueprint.isGlobal ? "Remove from global columns" : "Make fields available as global columns"}
>
{blueprint.isGlobal ? "Unglobalize" : "Make Global"}
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete blueprint "${blueprint.name}"?`)) {
onDelete();
}
}}
className="px-3 py-1.5 border border-red-200 text-red-600 rounded-lg hover:bg-red-50 text-sm font-medium transition-colors disabled:opacity-50"
>
Delete
</button>
</div>
</div>
);
}
export function BlueprintsClient() {
const [showNewModal, setShowNewModal] = useState(false);
const [editingBlueprint, setEditingBlueprint] = useState<BlueprintRow | null>(null);
const [editingTab, setEditingTab] = useState<"fields" | "presets">("fields");
const [targetFilter, setTargetFilter] = useState<string>("");
const [confirmBatchDelete, setConfirmBatchDelete] = useState<string[] | null>(null);
const selection = useSelection();
const utils = trpc.useUtils();
const { data, isLoading, isError } = trpc.blueprint.list.useQuery({
target: (targetFilter as BlueprintTarget) || undefined,
});
const batchDeleteMutation = trpc.blueprint.batchDelete.useMutation();
const deleteMutation = trpc.blueprint.delete.useMutation();
const setGlobalMutation = trpc.blueprint.setGlobal.useMutation();
const viewPrefs = useViewPrefs("blueprints");
useEffect(() => {
selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetFilter]);
const blueprints: BlueprintRow[] = data ?? [];
const { sorted: sortedBlueprints, sortField, sortDir, toggle } = useTableSort<BlueprintRow, BlueprintSortField>(blueprints, {
initialField: (viewPrefs.savedSort?.field as BlueprintSortField | undefined) ?? null,
initialDir: viewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
const blueprintIds = sortedBlueprints.map((b) => b.id);
function handleSort(field: BlueprintSortField) {
switch (field) {
case "fieldCount":
toggle(field, (row) => (Array.isArray(row.fieldDefs) ? row.fieldDefs.length : 0));
return;
case "presetCount":
toggle(field, (row) => (Array.isArray(row.rolePresets) ? row.rolePresets.length : 0));
return;
case "global":
toggle(field, (row) => (row.isGlobal ? 0 : 1));
return;
default:
toggle(field);
}
}
function handleSortRequest(field: string) {
handleSort(field as BlueprintSortField);
}
async function handleDelete(id: string) {
await deleteMutation.mutateAsync({ id });
await utils.blueprint.list.invalidate();
if (selection.selectedIds.has(id)) {
selection.toggle(id);
}
if (editingBlueprint?.id === id) {
setEditingBlueprint(null);
}
}
async function handleToggleGlobal(id: string, isGlobal: boolean | undefined) {
await setGlobalMutation.mutateAsync({ id, isGlobal: !isGlobal });
await utils.blueprint.list.invalidate();
}
async function handleBatchDelete(ids: string[]) {
await batchDeleteMutation.mutateAsync({ ids });
await utils.blueprint.list.invalidate();
selection.clear();
}
return (
<div className="p-6 pb-24">
<div className="flex items-start justify-between mb-6 gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Blueprints</h1>
<p className="text-gray-500 text-sm mt-1">Configure dynamic fields for resources and projects</p>
</div>
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
+ New Blueprint
</button>
</div>
<FilterBar
hasActiveFilters={!!targetFilter}
onClearFilters={() => setTargetFilter("")}
>
<select
value={targetFilter}
onChange={(e) => setTargetFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
>
<option value="">All Targets</option>
<option value="RESOURCE">Resource</option>
<option value="PROJECT">Project</option>
</select>
</FilterBar>
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-5 animate-pulse h-36" />
))}
</div>
)}
{isError && (
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-red-700 text-sm">
Failed to load blueprints. Please refresh the page.
</div>
)}
{!isLoading && !isError && blueprints.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-gray-400 text-sm mb-4">No blueprints yet. Create one to start defining dynamic fields.</p>
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
+ New Blueprint
</button>
</div>
)}
{!isLoading && !isError && sortedBlueprints.length > 0 && (
<>
<div className="hidden md:block bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="w-12 px-3 py-3">
<input
type="checkbox"
checked={selection.isAllSelected(blueprintIds)}
onChange={() => selection.toggleAll(blueprintIds)}
className="rounded border-gray-300"
aria-label="Select all blueprints"
/>
</th>
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} />
<SortableColumnHeader label="Target" field="target" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} />
<SortableColumnHeader label="Fields" field="fieldCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
<SortableColumnHeader label="Staffing Presets" field="presetCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
<SortableColumnHeader label="Global" field="global" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{sortedBlueprints.map((bp) => {
const fieldCount = Array.isArray(bp.fieldDefs) ? bp.fieldDefs.length : 0;
const presetCount = Array.isArray(bp.rolePresets) ? bp.rolePresets.length : 0;
const isProject = bp.target === "PROJECT";
return (
<tr key={bp.id} className="border-b border-gray-100 last:border-b-0 hover:bg-gray-50 transition-colors">
<td className="px-3 py-3">
<input
type="checkbox"
checked={selection.selectedIds.has(bp.id)}
onChange={() => selection.toggle(bp.id)}
className="rounded border-gray-300"
/>
</td>
<td className="px-3 py-3">
<div className="min-w-0">
<div className="font-medium text-gray-900">{bp.name}</div>
{bp.description && <div className="text-xs text-gray-500 mt-0.5 truncate">{bp.description}</div>}
</div>
</td>
<td className="px-3 py-3">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700" : "bg-blue-50 text-blue-700"}`}>
{bp.target}
</span>
</td>
<td className="px-3 py-3 text-center text-gray-600">{fieldCount}</td>
<td className="px-3 py-3 text-center text-gray-600">{isProject ? presetCount : "—"}</td>
<td className="px-3 py-3 text-center">
{bp.isGlobal ? (
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 font-medium">
Global
</span>
) : (
<span className="text-gray-300"></span>
)}
</td>
<td className="px-3 py-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => { setEditingTab("fields"); setEditingBlueprint(bp); }}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit Fields
</button>
{isProject && (
<button
type="button"
onClick={() => { setEditingTab("presets"); setEditingBlueprint(bp); }}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Presets
</button>
)}
<button
type="button"
onClick={() => handleToggleGlobal(bp.id, bp.isGlobal)}
disabled={setGlobalMutation.isPending}
className="text-xs text-gray-500 hover:text-gray-700 disabled:opacity-50"
>
{bp.isGlobal ? "Unglobalize" : "Make Global"}
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete blueprint "${bp.name}"?`)) {
handleDelete(bp.id);
}
}}
disabled={deleteMutation.isPending}
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
>
Delete
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="grid grid-cols-1 gap-4 md:hidden">
{sortedBlueprints.map((bp) => (
<BlueprintCard
key={bp.id}
blueprint={bp}
onEditFields={() => { setEditingTab("fields"); setEditingBlueprint(bp); }}
onEditStaffing={() => { setEditingTab("presets"); setEditingBlueprint(bp); }}
onToggleGlobal={() => handleToggleGlobal(bp.id, bp.isGlobal)}
onDelete={() => handleDelete(bp.id)}
isSelected={selection.selectedIds.has(bp.id)}
onToggleSelect={() => selection.toggle(bp.id)}
/>
))}
</div>
</>
)}
<BatchActionBar
count={selection.count}
onClear={selection.clear}
actions={[
{
label: `Delete (${selection.count})`,
variant: "danger",
onClick: () => setConfirmBatchDelete(selection.selectedArray),
disabled: batchDeleteMutation.isPending,
},
]}
/>
{confirmBatchDelete && (
<ConfirmDialog
title="Delete Blueprints"
message={`Delete ${confirmBatchDelete.length} selected blueprint${confirmBatchDelete.length !== 1 ? "s" : ""}? They will be marked as inactive.`}
confirmLabel="Delete All"
variant="danger"
onConfirm={() => {
void handleBatchDelete(confirmBatchDelete);
setConfirmBatchDelete(null);
}}
onCancel={() => setConfirmBatchDelete(null)}
/>
)}
{showNewModal && (
<NewBlueprintModal onClose={() => setShowNewModal(false)} onCreated={() => setShowNewModal(false)} />
)}
{editingBlueprint && (
<BlueprintFieldEditor
blueprintId={editingBlueprint.id}
blueprintName={editingBlueprint.name}
initialFieldDefs={Array.isArray(editingBlueprint.fieldDefs) ? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[]) : []}
initialRolePresets={Array.isArray(editingBlueprint.rolePresets) ? (editingBlueprint.rolePresets as import("@planarchy/shared").StaffingRequirement[]) : []}
initialTab={editingTab}
onClose={() => setEditingBlueprint(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,206 @@
"use client";
import { useState } from "react";
import type { StaffingRequirement } from "@planarchy/shared";
const INPUT_CLS =
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
const BTN_DANGER =
"px-2 py-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded text-sm transition-colors";
function makeEmptyPreset(): StaffingRequirement {
return {
id: crypto.randomUUID(),
role: "",
requiredSkills: [],
preferredSkills: [],
hoursPerDay: 8,
headcount: 1,
};
}
interface PresetRowProps {
preset: StaffingRequirement;
onChange: (preset: StaffingRequirement) => void;
onDelete: () => void;
}
function PresetRow({ preset, onChange, onDelete }: PresetRowProps) {
function update<K extends keyof StaffingRequirement>(key: K, value: StaffingRequirement[K]) {
onChange({ ...preset, [key]: value });
}
return (
<div className="border border-gray-200 rounded-lg p-3 bg-white">
<div className="flex flex-wrap items-center gap-2">
{/* Role */}
<input
type="text"
value={preset.role}
onChange={(e) => update("role", e.target.value)}
placeholder="Role name"
className={`${INPUT_CLS} flex-1 min-w-32`}
aria-label="Role name"
/>
{/* Required Skills */}
<div className="flex flex-col gap-0.5 flex-1 min-w-40">
<label className="text-xs text-gray-400">Required skills (comma-sep)</label>
<input
type="text"
value={preset.requiredSkills.join(", ")}
onChange={(e) =>
update(
"requiredSkills",
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="e.g. 3D Modeling, Lighting"
className={INPUT_CLS}
aria-label="Required skills"
/>
</div>
{/* Hours per day */}
<div className="flex flex-col gap-0.5 w-24">
<label className="text-xs text-gray-400">h/day</label>
<input
type="number"
value={preset.hoursPerDay}
min={0}
max={24}
step={0.5}
onChange={(e) => update("hoursPerDay", parseFloat(e.target.value) || 0)}
className={INPUT_CLS}
aria-label="Hours per day"
/>
</div>
{/* Headcount */}
<div className="flex flex-col gap-0.5 w-20">
<label className="text-xs text-gray-400">Count</label>
<input
type="number"
value={preset.headcount}
min={1}
max={20}
onChange={(e) => update("headcount", parseInt(e.target.value, 10) || 1)}
className={INPUT_CLS}
aria-label="Headcount"
/>
</div>
{/* Delete */}
<button
type="button"
onClick={onDelete}
className={`${BTN_DANGER} self-end mb-0.5`}
aria-label="Remove preset"
>
×
</button>
</div>
{/* Preferred skills (secondary row) */}
<div className="mt-2">
<label className="text-xs text-gray-400">Preferred skills (optional)</label>
<input
type="text"
value={(preset.preferredSkills ?? []).join(", ")}
onChange={(e) =>
update(
"preferredSkills",
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="e.g. Compositing, Art Direction"
className={`${INPUT_CLS} w-full mt-0.5`}
aria-label="Preferred skills"
/>
</div>
</div>
);
}
interface RolePresetsEditorProps {
initialPresets: StaffingRequirement[];
/** Called with the current presets array when the user clicks Save */
onSave: (presets: StaffingRequirement[]) => void;
isSaving?: boolean;
saveError?: string | null;
}
export function RolePresetsEditor({
initialPresets,
onSave,
isSaving = false,
saveError = null,
}: RolePresetsEditorProps) {
const [presets, setPresets] = useState<StaffingRequirement[]>(initialPresets);
function addPreset() {
setPresets((prev) => [...prev, makeEmptyPreset()]);
}
function removePreset(idx: number) {
setPresets((prev) => prev.filter((_, i) => i !== idx));
}
function updatePreset(idx: number, updated: StaffingRequirement) {
setPresets((prev) => prev.map((p, i) => (i === idx ? updated : p)));
}
return (
<div>
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
{presets.length === 0 && (
<p className="text-sm text-gray-400 text-center py-8">
No role presets yet. Click &ldquo;+ Add Role&rdquo; to define default staffing.
</p>
)}
{presets.map((preset, idx) => (
<PresetRow
key={preset.id}
preset={preset}
onChange={(updated) => updatePreset(idx, updated)}
onDelete={() => removePreset(idx)}
/>
))}
</div>
<div className="mt-3">
<button
type="button"
onClick={addPreset}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium"
>
<span className="text-lg leading-none">+</span> Add Role
</button>
</div>
{saveError && (
<div className="mt-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{saveError}
</div>
)}
<div className="flex justify-end mt-4">
<button
type="button"
onClick={() => onSave(presets)}
disabled={isSaving}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isSaving ? "Saving…" : "Save Presets"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,60 @@
"use client";
import type { DashboardWidgetType } from "@planarchy/shared/types";
import { WIDGET_CATALOG } from "./widget-registry.js";
interface AddWidgetModalProps {
onAdd: (type: DashboardWidgetType) => void;
onClose: () => void;
}
export function AddWidgetModal({ onAdd, onClose }: AddWidgetModalProps) {
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Add Widget</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
>
×
</button>
</div>
{/* Grid of widgets */}
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{WIDGET_CATALOG.map((def) => (
<button
key={def.type}
type="button"
onClick={() => {
onAdd(def.type);
onClose();
}}
className="flex items-start gap-4 p-4 border border-gray-200 rounded-xl hover:border-brand-400 hover:bg-brand-50 transition-colors text-left"
>
<span className="text-3xl shrink-0">{def.icon}</span>
<div>
<div className="font-semibold text-gray-900 text-sm">{def.label}</div>
<div className="text-xs text-gray-500 mt-1">{def.description}</div>
<div className="text-xs text-gray-400 mt-1">
Default: {def.defaultSize.w}×{def.defaultSize.h} grid units
</div>
</div>
</button>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,160 @@
"use client";
import type { DashboardWidgetConfig, DashboardWidgetType } from "@planarchy/shared/types";
import dynamic from "next/dynamic";
import { Suspense, useState, useRef, useEffect } from "react";
import { useDashboardLayout } from "~/hooks/useDashboardLayout.js";
import { WidgetContainer } from "./WidgetContainer.js";
import { AddWidgetModal } from "./AddWidgetModal.js";
import { getWidget } from "./widget-registry.js";
// Import CSS for react-grid-layout
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
function WidgetFallback() {
return (
<div className="animate-pulse h-full w-full flex flex-col gap-3 p-4">
<div className="h-3 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-full bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-3 w-4/5 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-3 w-3/5 bg-gray-100 dark:bg-gray-800 rounded" />
</div>
);
}
// Dynamic import — no WidthProvider (uses findDOMNode, broken in React 18 strict mode).
// We measure container width ourselves via ResizeObserver and pass it as a prop.
const GridLayout = dynamic(() => import("react-grid-layout").then((m) => m.Responsive), {
ssr: false,
});
function renderWidget(type: DashboardWidgetType, config: DashboardWidgetConfig, onConfigChange: (u: Record<string, unknown>) => void) {
const widget = getWidget(type);
const Component = widget.component;
return (
<Suspense fallback={<WidgetFallback />}>
<Component config={config as Record<string, unknown>} onConfigChange={onConfigChange} />
</Suspense>
);
}
export function DashboardClient() {
const [addModalOpen, setAddModalOpen] = useState(false);
const { config, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout } =
useDashboardLayout();
// Measure grid container width so Responsive knows the column size.
// We can't use WidthProvider (uses findDOMNode, deprecated in React 18).
const containerRef = useRef<HTMLDivElement>(null);
const [gridWidth, setGridWidth] = useState(1200);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
if (entry) setGridWidth(entry.contentRect.width);
});
ro.observe(el);
setGridWidth(el.getBoundingClientRect().width);
return () => ro.disconnect();
}, []);
const layouts = {
lg: config.widgets.map((w) => ({
i: w.id,
x: w.x,
y: w.y,
w: w.w,
h: w.h,
minW: w.minW ?? 2,
minH: w.minH ?? 2,
})),
};
return (
<div className="p-6">
{/* Toolbar */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-sm text-gray-500 mt-1">Drag to rearrange, resize from corners</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={resetLayout}
className="px-3 py-2 text-sm text-gray-500 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Reset
</button>
<button
type="button"
onClick={() => setAddModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Widget
</button>
</div>
</div>
{/* Grid */}
{config.widgets.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center border-2 border-dashed border-gray-200 rounded-xl">
<p className="text-gray-400 text-sm mb-4">No widgets yet. Add your first widget to get started.</p>
<button
type="button"
onClick={() => setAddModalOpen(true)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
+ Add Widget
</button>
</div>
) : (
<div ref={containerRef}>
{(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const AnyGridLayout = GridLayout as any;
return (
<AnyGridLayout
className="layout"
layouts={layouts}
width={gridWidth}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
rowHeight={80}
compactType={null}
preventCollision={false}
onLayoutChange={(_: unknown, allLayouts: Record<string, { i: string; x: number; y: number; w: number; h: number }[]>) => onLayoutChange(allLayouts["lg"] ?? [])}
draggableHandle=".widget-drag-handle"
margin={[12, 12]}
>
{config.widgets.map((widget) => (
<div key={widget.id}>
<WidgetContainer
title={widget.title ?? getWidget(widget.type).label}
onRemove={() => removeWidget(widget.id)}
>
{renderWidget(
widget.type,
widget.config,
(update) => updateWidgetConfig(widget.id, update),
)}
</WidgetContainer>
</div>
))}
</AnyGridLayout>
);
})()}
</div>
)}
{addModalOpen && (
<AddWidgetModal onAdd={addWidget} onClose={() => setAddModalOpen(false)} />
)}
</div>
);
}
@@ -0,0 +1,39 @@
"use client";
interface WidgetContainerProps {
title: string;
onRemove: () => void;
children: React.ReactNode;
isDragging?: boolean;
}
export function WidgetContainer({ title, onRemove, children, isDragging }: WidgetContainerProps) {
return (
<div
className={`flex flex-col h-full bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden ${
isDragging ? "shadow-lg border-brand-300" : ""
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-100 bg-gray-50/50 shrink-0 cursor-grab active:cursor-grabbing widget-drag-handle">
<span className="text-sm font-semibold text-gray-700 truncate">{title}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="ml-2 p-1 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded transition-colors shrink-0"
title="Remove widget"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-4">{children}</div>
</div>
);
}
@@ -0,0 +1,60 @@
import {
DASHBOARD_WIDGET_CATALOG,
type DashboardWidgetCatalogEntry,
type DashboardWidgetType,
} from "@planarchy/shared/types";
import { lazy, type ComponentType, type LazyExoticComponent } from "react";
type WidgetUpdate = Record<string, unknown>;
export interface WidgetProps {
config: Record<string, unknown>;
onConfigChange?: (update: WidgetUpdate) => void;
}
export type WidgetComponent = LazyExoticComponent<ComponentType<WidgetProps>>;
export interface WidgetDefinition extends DashboardWidgetCatalogEntry {
component: WidgetComponent;
}
export const WIDGET_CATALOG = DASHBOARD_WIDGET_CATALOG;
export const WIDGET_REGISTRY: Record<DashboardWidgetType, WidgetDefinition> = {
"stat-cards": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "stat-cards")!,
component: lazy(() => import("./widgets/StatCardsWidget.js").then((m) => ({ default: m.StatCardsWidget }))),
},
"resource-table": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "resource-table")!,
component: lazy(() => import("./widgets/ResourceTableWidget.js").then((m) => ({ default: m.ResourceTableWidget }))),
},
"project-table": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "project-table")!,
component: lazy(() => import("./widgets/ProjectTableWidget.js").then((m) => ({ default: m.ProjectTableWidget }))),
},
"peak-times-chart": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "peak-times-chart")!,
component: lazy(() => import("./widgets/PeakTimesWidget.js").then((m) => ({ default: m.PeakTimesWidget }))),
},
"demand-view": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "demand-view")!,
component: lazy(() => import("./widgets/DemandWidget.js").then((m) => ({ default: m.DemandWidget }))),
},
"top-value-resources": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "top-value-resources")!,
component: lazy(() => import("./widgets/TopValueWidget.js").then((m) => ({ default: m.TopValueWidget }))),
},
"chargeability-overview": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "chargeability-overview")!,
component: lazy(() => import("./widgets/ChargeabilityWidget.js").then((m) => ({ default: m.ChargeabilityWidget }))),
},
};
export function getWidget(type: DashboardWidgetType): WidgetDefinition {
return WIDGET_REGISTRY[type];
}
export function getAllWidgets(): WidgetDefinition[] {
return Object.values(WIDGET_REGISTRY);
}
@@ -0,0 +1,224 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
type TopSortKey = "name" | "actual" | "expected";
type WatchSortKey = "name" | "actual" | "target";
export function ChargeabilityWidget({ config: _config }: WidgetProps) {
const config = _config as { topN?: number; watchlistThreshold?: number };
const [topSort, setTopSort] = useState<TopSortKey>("actual");
const [topDir, setTopDir] = useState<"asc" | "desc">("desc");
const [watchSort, setWatchSort] = useState<WatchSortKey>("actual");
const [watchDir, setWatchDir] = useState<"asc" | "desc">("asc");
function toggleTop(key: TopSortKey) {
if (topSort === key) setTopDir((d) => (d === "asc" ? "desc" : "asc"));
else { setTopSort(key); setTopDir(key === "name" ? "asc" : "desc"); }
}
function toggleWatch(key: WatchSortKey) {
if (watchSort === key) setWatchDir((d) => (d === "asc" ? "desc" : "asc"));
else { setWatchSort(key); setWatchDir(key === "name" ? "asc" : "asc"); }
}
const { data, isLoading } = trpc.dashboard.getChargeabilityOverview.useQuery(
{ topN: config.topN ?? 10, watchlistThreshold: config.watchlistThreshold ?? 15 },
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
if (isLoading) {
return (
<div className="animate-pulse flex flex-col gap-3 pt-1">
<div className="h-2 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
{[...Array(4)].map((_, i) => (
<div key={i} className="flex gap-3 px-2 py-1">
<div className="h-3 w-4 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
<div className="border-t border-gray-100 dark:border-gray-800 mt-1 pt-2">
<div className="h-2 w-20 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
{[...Array(3)].map((_, i) => (
<div key={i} className="flex gap-3 px-2 py-1">
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
</div>
))}
</div>
</div>
);
}
const rawTop = data?.top ?? [];
const rawWatch = data?.watchlist ?? [];
const month = data?.month ?? "";
const top = [...rawTop].sort((a, b) => {
const mult = topDir === "asc" ? 1 : -1;
switch (topSort) {
case "name": return mult * a.displayName.localeCompare(b.displayName);
case "actual": return mult * (a.actualChargeability - b.actualChargeability);
case "expected": return mult * (a.expectedChargeability - b.expectedChargeability);
default: return 0;
}
});
const watchlist = [...rawWatch].sort((a, b) => {
const mult = watchDir === "asc" ? 1 : -1;
switch (watchSort) {
case "name": return mult * a.displayName.localeCompare(b.displayName);
case "actual": return mult * (a.actualChargeability - b.actualChargeability);
case "target": return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
default: return 0;
}
});
function TopInd({ k }: { k: TopSortKey }) {
return topSort === k
? <span className="text-[10px] ml-0.5">{topDir === "asc" ? "▲" : "▼"}</span>
: <span className="text-[10px] ml-0.5 text-gray-300"></span>;
}
function WatchInd({ k }: { k: WatchSortKey }) {
return watchSort === k
? <span className="text-[10px] ml-0.5">{watchDir === "asc" ? "▲" : "▼"}</span>
: <span className="text-[10px] ml-0.5 text-gray-300"></span>;
}
return (
<div className="h-full flex flex-col gap-2 overflow-hidden">
{month && (
<p className="text-xs text-gray-400 px-1 flex-shrink-0 flex items-center gap-1">
Period: {month}
<InfoTooltip
content="Chargeability is calculated for the current calendar month. Available hours are based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
width="w-72"
/>
</p>
)}
{/* Top list */}
<section className="flex-1 min-h-0 overflow-auto">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
Top Chargeability
</h3>
{top.length === 0 ? (
<p className="text-xs text-gray-400 px-1">No data available.</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="px-2 py-1 text-left font-medium w-6">#</th>
<th className="px-2 py-1 text-left font-medium">
<button type="button" onClick={() => toggleTop("name")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Name<TopInd k="name" />
</button>
</th>
<th className="px-2 py-1 text-right font-medium">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleTop("actual")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Actual<TopInd k="actual" />
</button>
<InfoTooltip
content="CONFIRMED + ACTIVE allocations on non-DRAFT projects ÷ available working hours this month × 100."
width="w-72"
/>
</span>
</th>
<th className="px-2 py-1 text-right font-medium">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleTop("expected")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Expected<TopInd k="expected" />
</button>
<InfoTooltip
content="All non-CANCELLED allocations (including DRAFT projects and PROPOSED status) ÷ available working hours this month × 100."
width="w-72"
/>
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{top.map((r, i) => (
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
<td className="px-2 py-1 text-gray-800 truncate max-w-[120px]">
<span title={r.displayName}>{r.displayName}</span>
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
</td>
<td className="px-2 py-1 text-right font-semibold text-green-700">
{r.actualChargeability}%
</td>
<td className="px-2 py-1 text-right text-gray-400">
{r.expectedChargeability}%
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<div className="border-t border-gray-100 flex-shrink-0" />
{/* Watchlist */}
<section className="flex-1 min-h-0 overflow-auto">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
Watchlist <span className="font-normal text-gray-400">(below target)</span>
</h3>
{watchlist.length === 0 ? (
<p className="text-xs text-gray-400 px-1">All resources at or near target.</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="px-2 py-1 text-left font-medium">
<button type="button" onClick={() => toggleWatch("name")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Name<WatchInd k="name" />
</button>
</th>
<th className="px-2 py-1 text-right font-medium">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleWatch("actual")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Actual<WatchInd k="actual" />
</button>
<InfoTooltip content="Actual chargeability this month: CONFIRMED + ACTIVE allocations on non-DRAFT projects ÷ available hours." />
</span>
</th>
<th className="px-2 py-1 text-right font-medium">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleWatch("target")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Target<WatchInd k="target" />
</button>
<InfoTooltip content="Chargeability target set by management. Watchlist shows resources more than 15 percentage points below their target." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{watchlist.map((r) => (
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-2 py-1 text-gray-800 truncate max-w-[140px]">
<span title={r.displayName}>{r.displayName}</span>
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
</td>
<td className="px-2 py-1 text-right font-semibold text-red-600">
{r.actualChargeability}%
</td>
<td className="px-2 py-1 text-right text-gray-400">
{r.chargeabilityTarget}%
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
);
}
@@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
type GroupBy = "project" | "person" | "chapter";
export function DemandWidget({ config, onConfigChange }: WidgetProps) {
const groupBy = (config.groupBy as GroupBy) || "project";
type SortKey = "name" | "allocatedHours" | "requiredFTEs" | "resourceCount";
const [sortKey, setSortKey] = useState<SortKey>("allocatedHours");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir(key === "name" ? "asc" : "desc"); }
}
const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
const endDate = new Date(now.getFullYear(), now.getMonth() + 3, 0).toISOString();
const { data, isLoading, isFetching } = trpc.dashboard.getDemand.useQuery(
{ startDate, endDate, groupBy },
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
if (isLoading && !data) {
return (
<div className="animate-pulse flex flex-col gap-3 pt-1">
<div className="flex gap-1 border-b border-gray-200 pb-1">
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
{[...Array(6)].map((_, i) => (
<div key={i} className="flex gap-3 px-3 py-1.5">
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
);
}
const rows = data ?? [];
const sorted = [...rows].sort((a, b) => {
const mult = sortDir === "asc" ? 1 : -1;
switch (sortKey) {
case "name": return mult * a.name.localeCompare(b.name);
case "allocatedHours": return mult * (a.allocatedHours - b.allocatedHours);
case "requiredFTEs": return mult * ((a.requiredFTEs as unknown as number ?? 0) - (b.requiredFTEs as unknown as number ?? 0));
case "resourceCount": return mult * (a.resourceCount - b.resourceCount);
default: return 0;
}
});
function Ind({ k }: { k: SortKey }) {
return sortKey === k
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "▲" : "▼"}</span>
: <span className="text-[10px] ml-0.5 text-gray-300"></span>;
}
return (
<div className="flex flex-col h-full gap-3">
{/* Tab bar */}
<div className="flex gap-1 border-b border-gray-200">
{(["project", "person", "chapter"] as GroupBy[]).map((g) => (
<button
key={g}
type="button"
onClick={() => onConfigChange?.({ groupBy: g })}
className={`px-3 py-1.5 text-xs font-medium capitalize transition-colors ${
groupBy === g
? "border-b-2 border-brand-600 text-brand-700"
: "text-gray-500 hover:text-gray-700"
}`}
>
Per {g === "person" ? "Person" : g === "project" ? "Project" : "Chapter"}
</button>
))}
</div>
{/* Table */}
<div className={`overflow-auto flex-1 transition-opacity duration-150 ${isFetching ? "opacity-60" : "opacity-100"}`}>
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
{groupBy === "project" ? "Project" : groupBy === "person" ? "Person" : "Chapter"}
<Ind k="name" />
</button>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("allocatedHours")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Allocated h<Ind k="allocatedHours" />
</button>
<InfoTooltip
content="Total booked hours from active assignments in the current quarter."
position="bottom"
/>
</span>
</th>
{groupBy === "project" && (
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("requiredFTEs")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Req. FTEs<Ind k="requiredFTEs" />
</button>
<InfoTooltip
content="Planned demand from demand requirements, with fallback to project staffing requirements for legacy projects. Red = booked hours fall short of the planned demand."
position="bottom"
/>
</span>
</th>
)}
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("resourceCount")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
{groupBy === "person" ? "Projects" : "Resources"}<Ind k="resourceCount" />
</button>
{groupBy === "person" ? (
<InfoTooltip content="Number of distinct projects this person is allocated to in the period." position="bottom" />
) : (
<InfoTooltip content="Number of distinct resources allocated to this project/chapter in the period." position="bottom" />
)}
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900 max-w-[200px] truncate">
{groupBy === "project" ? (
<span><span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>{row.name}</span>
) : (
row.name
)}
</td>
<td className="px-3 py-2 text-right text-gray-700">{row.allocatedHours}h</td>
{groupBy === "project" && (
<td className="px-3 py-2 text-right text-gray-700">
{(() => {
const ftes = row.requiredFTEs as unknown as number;
return ftes > 0 ? (
<span className={row.allocatedHours / 8 < ftes * 22 * 3 ? "text-red-600 font-semibold" : "text-green-700"}>
{ftes} FTE
</span>
) : "—";
})()}
</td>
)}
<td className="px-3 py-2 text-right text-gray-500">{row.resourceCount}</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 && (
<div className="text-center py-8 text-sm text-gray-400">No demand data found.</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,138 @@
"use client";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceLine,
ResponsiveContainer,
Legend,
} from "recharts";
const COLORS = [
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6",
];
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
const granularity = (config.granularity as "week" | "month") || "month";
const groupBy = (config.groupBy as "project" | "chapter" | "resource") || "project";
const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString();
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0).toISOString();
const { data, isLoading } = trpc.dashboard.getPeakTimes.useQuery(
{ startDate, endDate, granularity, groupBy },
{ staleTime: 120_000, placeholderData: (prev) => prev },
);
if (isLoading) {
return (
<div className="animate-pulse flex flex-col gap-3 h-full pt-2">
<div className="flex gap-2">
<div className="h-7 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
<div className="h-7 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
<div className="flex items-end gap-1 flex-1 px-2">
{[...Array(12)].map((_, i) => (
<div
key={i}
className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-t"
style={{ height: `${30 + Math.random() * 50}%` }}
/>
))}
</div>
</div>
);
}
const periods = data ?? [];
// Collect all group names
const allGroups = new Set<string>();
for (const p of periods) {
for (const g of p.groups) allGroups.add(g.name);
}
const groups = [...allGroups].slice(0, 10);
// Build recharts data
const chartData = periods.map((p) => {
const row: Record<string, number | string> = { period: p.period, capacity: p.capacityHours };
for (const g of p.groups) {
row[g.name] = g.hours;
}
return row;
});
return (
<div className="flex flex-col h-full gap-3">
{/* Controls + info */}
<div className="flex gap-2 items-center">
<select
value={granularity}
onChange={(e) => onConfigChange?.({ granularity: e.target.value })}
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="month">Monthly</option>
<option value="week">Weekly</option>
</select>
<select
value={groupBy}
onChange={(e) => onConfigChange?.({ groupBy: e.target.value })}
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="project">By Project</option>
<option value="chapter">By Chapter</option>
<option value="resource">By Resource</option>
</select>
<InfoTooltip
content={
<span>
Stacked bars = booked hours per group per period (last 2 months to next 6 months).<br />
Red dashed line = total capacity estimate (all active resources × available hours per day × working days).<br />
Bars exceeding the capacity line indicate over-allocation risk.
</span>
}
width="w-80"
position="bottom"
/>
</div>
{/* Chart */}
<div className="flex-1 min-h-0">
{chartData.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No allocation data in selected period.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="period" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<ReferenceLine
{...({ dataKey: "capacity" } as any)}
stroke="#ef4444"
strokeDasharray="5 5"
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }}
/>
{groups.map((g, i) => (
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} />
))}
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
@@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { ProjectStatus } from "@planarchy/shared/types";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const STATUS_COLORS: Record<string, string> = {
DRAFT: "bg-gray-100 text-gray-700",
ACTIVE: "bg-green-100 text-green-700",
ON_HOLD: "bg-yellow-100 text-yellow-700",
COMPLETED: "bg-blue-100 text-blue-700",
CANCELLED: "bg-red-100 text-red-700",
};
export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
const status = (config.status as ProjectStatus) || undefined;
const search = (config.search as string) || "";
const { data: projects, isLoading } = trpc.project.listWithCosts.useQuery(
{ status, search: search || undefined },
{ staleTime: 60_000 },
);
type SortKey = "code" | "name" | "status" | "cost" | "personDays";
const [sortKey, setSortKey] = useState<SortKey>("name");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
}
if (isLoading) {
return (
<div className="animate-pulse flex flex-col gap-2 pt-1">
{/* header row */}
<div className="flex gap-3 px-3 py-2">
{[40, 120, 80, 60, 60].map((w, i) => (
<div key={i} className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded" style={{ width: w }} />
))}
</div>
{/* data rows */}
{[...Array(6)].map((_, i) => (
<div key={i} className="flex gap-3 px-3 py-2 border-t border-gray-100 dark:border-gray-800">
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
);
}
interface ProjectRow {
id: string;
shortCode: string;
name: string;
status: string;
totalCostCents: number;
totalPersonDays: number;
}
const list = ((projects as unknown as { projects: ProjectRow[] } | undefined)?.projects ?? []) as ProjectRow[];
const sorted = [...list].sort((a, b) => {
const mult = sortDir === "asc" ? 1 : -1;
switch (sortKey) {
case "code": return mult * a.shortCode.localeCompare(b.shortCode);
case "name": return mult * a.name.localeCompare(b.name);
case "status": return mult * a.status.localeCompare(b.status);
case "cost": return mult * (a.totalCostCents - b.totalCostCents);
case "personDays": return mult * (a.totalPersonDays - b.totalPersonDays);
default: return 0;
}
});
return (
<div className="flex flex-col h-full gap-3">
{/* Filters */}
<div className="flex gap-2">
<input
type="search"
placeholder="Search projects..."
value={search}
onChange={(e) => onConfigChange?.({ search: e.target.value })}
className="flex-1 min-w-0 px-2 py-1 text-xs border border-gray-300 rounded-lg"
/>
<select
value={status ?? ""}
onChange={(e) => onConfigChange?.({ status: e.target.value || undefined })}
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="">All Statuses</option>
{Object.values(ProjectStatus).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{/* Table */}
<div className="overflow-auto flex-1">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("code")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Code
<span className="text-[10px] ml-0.5">{sortKey === "code" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Name
<span className="text-[10px] ml-0.5">{sortKey === "name" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("status")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Status
<span className="text-[10px] ml-0.5">{sortKey === "status" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("cost")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Cost
<span className="text-[10px] ml-0.5">{sortKey === "cost" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
<InfoTooltip
content="Sum of (resource LCR × hours per day × working days) across all non-cancelled allocations on this project."
width="w-72"
/>
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("personDays")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Person Days
<span className="text-[10px] ml-0.5">{sortKey === "personDays" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
<InfoTooltip content="Total working days allocated across all non-cancelled allocations (sum of allocation durations in working days)." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.map((p) => (
<tr key={p.id} className="hover:bg-gray-50">
<td className="px-3 py-2 font-mono text-gray-600">{p.shortCode}</td>
<td className="px-3 py-2 font-medium text-gray-900 max-w-[180px] truncate">{p.name}</td>
<td className="px-3 py-2">
<span className={`inline-block px-1.5 py-0.5 rounded-full text-xs ${STATUS_COLORS[p.status] ?? ""}`}>
{p.status}
</span>
</td>
<td className="px-3 py-2 text-right text-gray-700">
{(p.totalCostCents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })}
</td>
<td className="px-3 py-2 text-right text-gray-700">{p.totalPersonDays}d</td>
</tr>
))}
</tbody>
</table>
{list.length === 0 && (
<div className="text-center py-8 text-sm text-gray-400">No projects found.</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,182 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
interface ResourceRow {
id: string;
eid: string;
displayName: string;
chapter: string | null;
chargeabilityTarget: number;
bookingCount: number;
utilizationPercent: number;
isOverbooked: boolean;
}
export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
const chapter = (config.chapter as string) || "";
const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
const endDate = new Date(now.getFullYear(), now.getMonth() + 3, 0).toISOString();
const { data: resources, isLoading } = trpc.resource.listWithUtilization.useQuery(
{ chapter: chapter || undefined, startDate, endDate },
{ staleTime: 60_000 },
);
const { data: chapterData } = trpc.resource.chapters.useQuery(undefined, { staleTime: 120_000 });
const chapters = chapterData ?? [];
type SortKey = "eid" | "name" | "chapter" | "bookings" | "utilization" | "target";
const [sortKey, setSortKey] = useState<SortKey>("name");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
}
if (isLoading) {
return (
<div className="animate-pulse flex flex-col gap-2 pt-1">
{/* header row */}
<div className="flex gap-3 px-3 py-2">
{[40, 120, 80, 60, 60].map((w, i) => (
<div key={i} className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded" style={{ width: w }} />
))}
</div>
{/* data rows */}
{[...Array(6)].map((_, i) => (
<div key={i} className="flex gap-3 px-3 py-2 border-t border-gray-100 dark:border-gray-800">
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
);
}
const list = (resources ?? []) as unknown as ResourceRow[];
const sorted = [...list].sort((a, b) => {
const mult = sortDir === "asc" ? 1 : -1;
switch (sortKey) {
case "eid": return mult * a.eid.localeCompare(b.eid);
case "name": return mult * a.displayName.localeCompare(b.displayName);
case "chapter": return mult * (a.chapter ?? "").localeCompare(b.chapter ?? "");
case "bookings": return mult * (a.bookingCount - b.bookingCount);
case "utilization": return mult * (a.utilizationPercent - b.utilizationPercent);
case "target": return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
default: return 0;
}
});
return (
<div className="flex flex-col h-full gap-3">
{/* Filter */}
{chapters.length > 0 && (
<select
value={chapter}
onChange={(e) => onConfigChange?.({ chapter: e.target.value })}
className="w-40 px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="">All Chapters</option>
{chapters.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
)}
{/* Table */}
<div className="overflow-auto flex-1">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<span className="inline-flex items-center">
<button type="button" onClick={() => toggleSort("eid")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
EID
<span className="text-[10px] ml-0.5">{sortKey === "eid" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
<InfoTooltip content="Employee ID — unique identifier for each resource." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Name
<span className="text-[10px] ml-0.5">{sortKey === "name" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("chapter")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Chapter
<span className="text-[10px] ml-0.5">{sortKey === "chapter" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("bookings")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Bookings
<span className="text-[10px] ml-0.5">{sortKey === "bookings" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
<InfoTooltip content="Number of non-cancelled allocations in the period (current month + next 3 months)." />
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("utilization")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Utilization
<span className="text-[10px] ml-0.5">{sortKey === "utilization" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
<InfoTooltip
content={
<span>
Booked hours ÷ available hours × 100 for the period.<br />
Available hours = working days × hours from personal schedule.<br />
<span className="text-orange-300">Orange</span> = &gt;85% · <span className="text-red-300">Red</span> = &gt;100%
</span>
}
width="w-72"
/>
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("target")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
Target
<span className="text-[10px] ml-0.5">{sortKey === "target" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
</button>
<InfoTooltip content="Chargeability target set by management per resource. Not a computed value." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.map((r) => (
<tr key={r.id} className={`hover:bg-gray-50 ${r.isOverbooked ? "bg-amber-50" : ""}`}>
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td>
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "—"}</td>
<td className="px-3 py-2 text-right text-gray-700">{r.bookingCount}</td>
<td className="px-3 py-2 text-right">
<span className={`font-semibold ${r.utilizationPercent > 100 ? "text-red-600" : r.utilizationPercent > 85 ? "text-orange-600" : "text-green-700"}`}>
{r.utilizationPercent}%
</span>
</td>
<td className="px-3 py-2 text-right text-gray-500">{r.chargeabilityTarget}%</td>
</tr>
))}
</tbody>
</table>
{list.length === 0 && (
<div className="text-center py-8 text-sm text-gray-400">No resources found.</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
function formatMoney(cents: number): string {
return (cents / 100).toLocaleString("de-DE") + " EUR";
}
function StatCard({ label, value, sub, info }: { label: string; value: string | number; sub?: string; info?: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-gray-500 flex items-center">
{label}
{info && <InfoTooltip content={info} />}
</span>
<span className="text-2xl font-bold text-gray-900">{value}</span>
{sub && <span className="text-xs text-gray-400">{sub}</span>}
</div>
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
const { data, isLoading } = trpc.dashboard.getOverview.useQuery(undefined, {
staleTime: 60_000,
placeholderData: (prev) => prev,
});
if (isLoading || !data) {
return (
<div className="grid grid-cols-2 gap-3 h-full animate-pulse">
{[...Array(4)].map((_, i) => (
<div key={i} className="rounded-xl bg-gray-100 dark:bg-gray-800 p-4 flex flex-col gap-2">
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-7 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-2 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
);
}
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 h-full content-start">
<StatCard
label="Total Resources"
value={data.totalResources}
sub={`${data.activeResources} active`}
info="All resources in the system. Sub-line shows active resources only."
/>
<StatCard
label="Active Projects"
value={data.activeProjects}
sub={`${data.totalProjects} total`}
info="Projects with status ACTIVE. Total includes all statuses (DRAFT, ON_HOLD, COMPLETED, CANCELLED)."
/>
<StatCard
label="Total Allocations"
value={data.totalAllocations}
sub={`${data.activeAllocations} not cancelled`}
info="All allocation records ever created. 'Not cancelled' excludes allocations with status CANCELLED."
/>
<StatCard
label="Budget Utilization"
value={`${data.budgetSummary.avgUtilizationPercent}%`}
sub={`${formatMoney(data.budgetSummary.totalCostCents)} of ${formatMoney(data.budgetSummary.totalBudgetCents)}`}
info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost = resource LCR × booked hours."
/>
</div>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
type SortKey = "eid" | "name" | "chapter" | "score" | "lcr";
export function TopValueWidget({ config }: WidgetProps) {
const limit = (config.limit as number) || 10;
const [sortKey, setSortKey] = useState<SortKey>("score");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir(key === "score" ? "desc" : "asc"); }
}
const { data, isLoading } = trpc.dashboard.getTopValueResources.useQuery(
{ limit },
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
if (isLoading) {
return (
<div className="animate-pulse flex flex-col gap-1 pt-1">
{[...Array(8)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2">
<div className="h-3 w-4 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-5 w-10 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
);
}
const list = data ?? [];
if (list.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-center py-8 text-gray-400 text-sm">
<p>No scores computed yet or you lack access.</p>
<p className="text-xs mt-1">Admins can recompute scores in Settings.</p>
</div>
);
}
const sorted = [...list].sort((a, b) => {
const mult = sortDir === "asc" ? 1 : -1;
switch (sortKey) {
case "eid": return mult * a.eid.localeCompare(b.eid);
case "name": return mult * a.displayName.localeCompare(b.displayName);
case "chapter": return mult * (a.chapter ?? "").localeCompare(b.chapter ?? "");
case "score": return mult * ((a.valueScore ?? 0) - (b.valueScore ?? 0));
case "lcr": return mult * (a.lcrCents - b.lcrCents);
default: return 0;
}
});
function Ind({ k }: { k: SortKey }) {
return sortKey === k
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "▲" : "▼"}</span>
: <span className="text-[10px] ml-0.5 text-gray-300"></span>;
}
return (
<div className="overflow-auto h-full">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 w-8">#</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("eid")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
EID<Ind k="eid" />
</button>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Name<Ind k="name" />
</button>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("chapter")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Chapter<Ind k="chapter" />
</button>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("score")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
Score<Ind k="score" />
</button>
<InfoTooltip
content={
<span>
Composite price/quality score 0100.<br />
Weights: Skill Depth 30% · Cost Efficiency 25% · Skill Breadth 15% · Chargeability 15% · Experience 15%.<br />
Recompute in Admin Settings.
</span>
}
width="w-72"
/>
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("lcr")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
LCR ()<Ind k="lcr" />
</button>
<InfoTooltip content="Labour Cost Rate — hourly cost in EUR. Lower LCR = better cost efficiency score." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.map((r, i) => (
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-3 py-2 text-gray-400 font-medium">{i + 1}</td>
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td>
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "—"}</td>
<td className="px-3 py-2 text-right">
<span
className={`inline-block px-2 py-0.5 rounded-full font-semibold ${
(r.valueScore ?? 0) >= 70
? "bg-green-100 text-green-700"
: (r.valueScore ?? 0) >= 40
? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700"
}`}
>
{r.valueScore ?? "—"}
</span>
</td>
<td className="px-3 py-2 text-right text-gray-700">{(r.lcrCents / 100).toFixed(0)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,316 @@
"use client";
import { clsx } from "clsx";
import { DateInput } from "~/components/ui/DateInput.js";
import { FieldType } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
interface Props {
fieldDefs: BlueprintFieldDefinition[];
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
errors?: Record<string, string>;
className?: string;
}
const INPUT_BASE =
"w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors";
const INPUT_NORMAL = "border-gray-300 bg-white text-gray-900";
const INPUT_ERROR = "border-red-400 bg-red-50 text-gray-900";
function inputClass(hasError: boolean) {
return clsx(INPUT_BASE, hasError ? INPUT_ERROR : INPUT_NORMAL);
}
interface FieldInputProps {
fieldDef: BlueprintFieldDefinition;
value: unknown;
onChange: (key: string, value: unknown) => void;
hasError: boolean;
}
function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
const { key, type, placeholder, validation, options } = fieldDef;
switch (type) {
case FieldType.TEXT:
return (
<input
type="text"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder}
maxLength={validation?.maxLength}
minLength={validation?.minLength}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
/>
);
case FieldType.TEXTAREA:
return (
<textarea
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder}
maxLength={validation?.maxLength}
onChange={(e) => onChange(key, e.target.value)}
className={clsx(inputClass(hasError), "resize-y min-h-[80px]")}
rows={3}
/>
);
case FieldType.NUMBER:
return (
<input
type="number"
id={key}
value={value !== undefined && value !== null && value !== "" ? Number(value) : ""}
placeholder={placeholder}
min={validation?.min}
max={validation?.max}
onChange={(e) =>
onChange(key, e.target.value === "" ? "" : Number(e.target.value))
}
className={inputClass(hasError)}
/>
);
case FieldType.BOOLEAN: {
const checked = value === true || value === "true" || value === 1;
return (
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
id={key}
checked={checked}
onChange={(e) => onChange(key, e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">
{checked ? "Yes" : "No"}
</span>
</label>
);
}
case FieldType.DATE:
return (
<DateInput
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
onChange={(v) => onChange(key, v)}
className={inputClass(hasError)}
/>
);
case FieldType.SELECT:
return (
<select
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
>
<option value="">{placeholder ?? "Select…"}</option>
{(options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
case FieldType.MULTI_SELECT: {
const selectedVals = Array.isArray(value) ? value.map(String) : [];
return (
<div className="space-y-1.5">
{(options ?? []).map((opt) => {
const checked = selectedVals.includes(opt.value);
return (
<label key={opt.value} className="inline-flex items-center gap-2 cursor-pointer mr-4">
<input
type="checkbox"
value={opt.value}
checked={checked}
onChange={(e) => {
const next = e.target.checked
? [...selectedVals, opt.value]
: selectedVals.filter((v) => v !== opt.value);
onChange(key, next);
}}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">{opt.label}</span>
</label>
);
})}
</div>
);
}
case FieldType.URL:
return (
<input
type="url"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder ?? "https://"}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
/>
);
case FieldType.EMAIL:
return (
<input
type="email"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder ?? "email@example.com"}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
/>
);
default:
return (
<input
type="text"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
/>
);
}
}
interface FieldWrapperProps {
fieldDef: BlueprintFieldDefinition;
value: unknown;
onChange: (key: string, value: unknown) => void;
error?: string;
}
function FieldWrapper({ fieldDef, value, onChange, error }: FieldWrapperProps) {
const hasError = Boolean(error);
return (
<div className="flex flex-col gap-1">
<label
htmlFor={fieldDef.key}
className="text-sm font-medium text-gray-700"
>
{fieldDef.label}
{fieldDef.required && (
<span className="ml-0.5 text-red-500" aria-hidden="true">
*
</span>
)}
</label>
<FieldInput
fieldDef={fieldDef}
value={value}
onChange={onChange}
hasError={hasError}
/>
{fieldDef.description && !error && (
<p className="text-xs text-gray-400">{fieldDef.description}</p>
)}
{error && (
<p className="text-xs text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}
function FieldGroup({
group,
fields,
values,
onChange,
errors,
}: {
group: string;
fields: BlueprintFieldDefinition[];
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
errors?: Record<string, string>;
}) {
return (
<fieldset className="space-y-4 border border-gray-200 rounded-lg p-4">
<legend className="text-sm font-semibold text-gray-700 px-1">{group}</legend>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{fields.map((field) => (
<FieldWrapper
key={field.id}
fieldDef={field}
value={values[field.key]}
onChange={onChange}
{...(errors?.[field.key] !== undefined ? { error: errors[field.key] } : {})}
/>
))}
</div>
</fieldset>
);
}
export function DynamicFieldEditor({
fieldDefs,
values,
onChange,
errors,
className,
}: Props) {
const sorted = [...fieldDefs].sort((a, b) => a.order - b.order);
const ungrouped = sorted.filter((f) => !f.group);
const groupMap = new Map<string, BlueprintFieldDefinition[]>();
for (const field of sorted) {
if (!field.group) continue;
const existing = groupMap.get(field.group) ?? [];
existing.push(field);
groupMap.set(field.group, existing);
}
if (sorted.length === 0) {
return null;
}
return (
<div className={clsx("space-y-6", className)}>
{ungrouped.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{ungrouped.map((field) => (
<FieldWrapper
key={field.id}
fieldDef={field}
value={values[field.key]}
onChange={onChange}
{...(errors?.[field.key] !== undefined ? { error: errors[field.key] } : {})}
/>
))}
</div>
)}
{[...groupMap.entries()].map(([group, fields]) => (
<FieldGroup
key={group}
group={group}
fields={fields}
values={values}
onChange={onChange}
{...(errors !== undefined ? { errors } : {})}
/>
))}
</div>
);
}
@@ -0,0 +1,152 @@
"use client";
import { clsx } from "clsx";
import { formatDateLong } from "~/lib/format.js";
import { FieldType } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
interface Props {
fieldDefs: BlueprintFieldDefinition[];
values: Record<string, unknown>;
className?: string;
}
function renderValue(fieldDef: BlueprintFieldDefinition, value: unknown): React.ReactNode {
if (value === null || value === undefined || value === "") {
return <span className="text-gray-400"></span>;
}
switch (fieldDef.type) {
case FieldType.TEXT:
case FieldType.TEXTAREA:
case FieldType.URL:
case FieldType.EMAIL:
return <span className="text-gray-900">{String(value)}</span>;
case FieldType.NUMBER:
return (
<span className="text-gray-900">
{typeof value === "number" ? value.toLocaleString() : String(value)}
</span>
);
case FieldType.BOOLEAN: {
const bool = value === true || value === "true" || value === 1;
return (
<span
className={clsx(
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
bool
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-500",
)}
>
{bool ? "Yes" : "No"}
</span>
);
}
case FieldType.DATE: {
const dateStr = String(value);
const parsed = new Date(dateStr);
if (isNaN(parsed.getTime())) {
return <span className="text-gray-900">{dateStr}</span>;
}
return <span className="text-gray-900">{formatDateLong(parsed)}</span>;
}
case FieldType.SELECT: {
const strVal = String(value);
const option = fieldDef.options?.find((o) => o.value === strVal);
return <span className="text-gray-900">{option?.label ?? strVal}</span>;
}
case FieldType.MULTI_SELECT: {
const rawVals = Array.isArray(value) ? value : [value];
const strVals = rawVals.map((v) => String(v)).filter(Boolean);
if (strVals.length === 0) {
return <span className="text-gray-400"></span>;
}
const labels = strVals.map((v) => {
const option = fieldDef.options?.find((o) => o.value === v);
return { value: v, label: option?.label ?? v, color: option?.color };
});
return (
<div className="flex flex-wrap gap-1">
{labels.map(({ value: v, label }) => (
<span
key={v}
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-brand-50 text-brand-700"
>
{label}
</span>
))}
</div>
);
}
default:
return <span className="text-gray-900">{String(value)}</span>;
}
}
function FieldRow({ fieldDef, value }: { fieldDef: BlueprintFieldDefinition; value: unknown }) {
return (
<div className="flex flex-col gap-0.5">
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wider">
{fieldDef.label}
</dt>
<dd className="text-sm">{renderValue(fieldDef, value)}</dd>
{fieldDef.description && (
<p className="text-xs text-gray-400">{fieldDef.description}</p>
)}
</div>
);
}
export function DynamicFieldRenderer({ fieldDefs, values, className }: Props) {
const sorted = [...fieldDefs].sort((a, b) => a.order - b.order);
// Separate grouped and ungrouped fields
const ungrouped = sorted.filter((f) => !f.group);
const groupMap = new Map<string, BlueprintFieldDefinition[]>();
for (const field of sorted) {
if (!field.group) continue;
const existing = groupMap.get(field.group) ?? [];
existing.push(field);
groupMap.set(field.group, existing);
}
if (sorted.length === 0) {
return null;
}
return (
<div className={clsx("space-y-6", className)}>
{ungrouped.length > 0 && (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{ungrouped.map((field) => (
<FieldRow key={field.id} fieldDef={field} value={values[field.key]} />
))}
</dl>
)}
{[...groupMap.entries()].map(([group, fields]) => (
<div key={group} className="space-y-3">
<h4 className="text-sm font-semibold text-gray-700 border-b border-gray-200 pb-1">
{group}
</h4>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{fields.map((field) => (
<FieldRow key={field.id} fieldDef={field} value={values[field.key]} />
))}
</dl>
</div>
))}
</div>
);
}
@@ -0,0 +1,2 @@
export { DynamicFieldRenderer } from "./DynamicFieldRenderer.js";
export { DynamicFieldEditor } from "./DynamicFieldEditor.js";
@@ -0,0 +1,215 @@
"use client";
import { useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
interface ApplyEffortRulesProps {
estimateId: string;
canEdit: boolean;
onApplied?: () => void;
}
export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffortRulesProps) {
const utils = trpc.useUtils();
const { data: ruleSets, isLoading } = trpc.effortRule.list.useQuery();
const [selectedRuleSetId, setSelectedRuleSetId] = useState<string>("");
const [mode, setMode] = useState<"replace" | "append">("replace");
const [showPreview, setShowPreview] = useState(false);
const previewQuery = trpc.effortRule.preview.useQuery(
{ estimateId, ruleSetId: selectedRuleSetId },
{ enabled: showPreview && Boolean(selectedRuleSetId) },
);
const applyMutation = trpc.effortRule.apply.useMutation({
onSuccess: (result) => {
utils.estimate.getById.invalidate();
setShowPreview(false);
onApplied?.();
if (result.warnings.length > 0) {
alert(`Generated ${result.linesGenerated} demand lines.\n\nWarnings:\n${result.warnings.join("\n")}`);
}
},
});
// Auto-select default rule set
if (!selectedRuleSetId && ruleSets) {
const defaultSet = ruleSets.find((rs) => rs.isDefault) ?? ruleSets[0];
if (defaultSet) {
setSelectedRuleSetId(defaultSet.id);
}
}
if (!canEdit) return null;
if (isLoading) {
return <p className="text-sm text-gray-400">Loading effort rules...</p>;
}
if (!ruleSets || ruleSets.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500">
No effort rule sets defined.{" "}
<a href="/admin/effort-rules" className="text-brand-600 hover:underline">
Create one
</a>{" "}
to auto-generate demand lines from scope items.
</div>
);
}
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Generate demand lines from scope</h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Rule set</span>
<select
value={selectedRuleSetId}
onChange={(e) => {
setSelectedRuleSetId(e.target.value);
setShowPreview(false);
}}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
>
{ruleSets.map((rs) => (
<option key={rs.id} value={rs.id}>
{rs.name} ({rs.rules.length} rules){rs.isDefault ? " *" : ""}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Mode</span>
<select
value={mode}
onChange={(e) => setMode(e.target.value as "replace" | "append")}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
>
<option value="replace">Replace existing lines</option>
<option value="append">Append to existing</option>
</select>
</label>
<button
onClick={() => setShowPreview(!showPreview)}
disabled={!selectedRuleSetId}
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{showPreview ? "Hide preview" : "Preview"}
</button>
<button
onClick={() => {
if (!selectedRuleSetId) return;
const action = mode === "replace" ? "replace all existing demand lines" : "append new demand lines";
if (confirm(`This will ${action}. Continue?`)) {
applyMutation.mutate({ estimateId, ruleSetId: selectedRuleSetId, mode });
}
}}
disabled={!selectedRuleSetId || applyMutation.isPending}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{applyMutation.isPending ? "Generating..." : "Apply rules"}
</button>
</div>
{applyMutation.error && (
<p className="mt-2 text-sm text-red-600">{applyMutation.error.message}</p>
)}
{/* Preview */}
{showPreview && previewQuery.data && (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
<span>{previewQuery.data.scopeItemCount} scope items</span>
<span>{previewQuery.data.ruleCount} rules</span>
<span className="font-semibold text-brand-700">{previewQuery.data.lines.length} demand lines would be generated</span>
{previewQuery.data.unmatchedScopeItems.length > 0 && (
<span className="text-amber-600">{previewQuery.data.unmatchedScopeItems.length} unmatched scope items</span>
)}
</div>
{previewQuery.data.warnings.length > 0 && (
<div className="rounded-xl bg-amber-50 p-3 text-sm text-amber-700">
{previewQuery.data.warnings.map((w, i) => (
<p key={i}>{w}</p>
))}
</div>
)}
{/* Aggregated discipline summary */}
{previewQuery.data.aggregated.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Discipline</th>
<th className="px-3 py-2 font-medium">Chapter</th>
<th className="px-3 py-2 text-right font-medium">Total hours</th>
<th className="pl-3 py-2 text-right font-medium">Lines</th>
</tr>
</thead>
<tbody>
{previewQuery.data.aggregated.map((agg, i) => (
<tr key={i} className="border-b border-gray-100">
<td className="py-1.5 pr-3 font-medium text-gray-900">{agg.discipline}</td>
<td className="px-3 py-1.5 text-gray-500">{agg.chapter ?? "\u2014"}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">{agg.totalHours.toFixed(1)} h</td>
<td className="pl-3 py-1.5 text-right tabular-nums text-gray-500">{agg.lineCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Detailed lines (collapsible) */}
{previewQuery.data.lines.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
Show all {previewQuery.data.lines.length} generated lines
</summary>
<div className="mt-2 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Scope item</th>
<th className="px-3 py-2 font-medium">Discipline</th>
<th className="px-3 py-2 font-medium">Chapter</th>
<th className="px-3 py-2 font-medium">Mode</th>
<th className="px-3 py-2 text-right font-medium">Units</th>
<th className="px-3 py-2 text-right font-medium">h/unit</th>
<th className="pl-3 py-2 text-right font-medium">Hours</th>
</tr>
</thead>
<tbody>
{previewQuery.data.lines.map((line, i) => (
<tr key={i} className={clsx("border-b border-gray-100", i % 2 === 0 ? "" : "bg-gray-50")}>
<td className="py-1.5 pr-3 text-gray-900">{line.scopeItemName}</td>
<td className="px-3 py-1.5 text-gray-700">{line.discipline}</td>
<td className="px-3 py-1.5 text-gray-500">{line.chapter ?? "\u2014"}</td>
<td className="px-3 py-1.5 text-gray-500">{line.unitMode}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-600">{line.unitCount}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-600">{line.hoursPerUnit}</td>
<td className="pl-3 py-1.5 text-right tabular-nums font-medium text-gray-900">{line.hours.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</div>
)}
{showPreview && previewQuery.isLoading && (
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
)}
</div>
);
}
@@ -0,0 +1,214 @@
"use client";
import { useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
interface ApplyExperienceMultipliersProps {
estimateId: string;
canEdit: boolean;
onApplied?: () => void;
}
function formatCents(cents: number): string {
return (cents / 100).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: ApplyExperienceMultipliersProps) {
const utils = trpc.useUtils();
const { data: sets, isLoading } = trpc.experienceMultiplier.list.useQuery();
const [selectedSetId, setSelectedSetId] = useState<string>("");
const [showPreview, setShowPreview] = useState(false);
const previewQuery = trpc.experienceMultiplier.preview.useQuery(
{ estimateId, multiplierSetId: selectedSetId },
{ enabled: showPreview && Boolean(selectedSetId) },
);
const applyMutation = trpc.experienceMultiplier.apply.useMutation({
onSuccess: (result) => {
utils.estimate.getById.invalidate();
setShowPreview(false);
onApplied?.();
alert(
`Updated ${result.linesUpdated} demand line(s).\n` +
`Hours: ${result.totalOriginalHours}h -> ${result.totalAdjustedHours}h`,
);
},
});
// Auto-select default set
if (!selectedSetId && sets) {
const defaultSet = sets.find((s) => s.isDefault) ?? sets[0];
if (defaultSet) {
setSelectedSetId(defaultSet.id);
}
}
if (!canEdit) return null;
if (isLoading) {
return <p className="text-sm text-gray-400">Loading experience multipliers...</p>;
}
if (!sets || sets.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500">
No experience multiplier sets defined.{" "}
<a href="/admin/experience-multipliers" className="text-brand-600 hover:underline">
Create one
</a>{" "}
to apply rate and effort adjustments.
</div>
);
}
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Apply experience multipliers</h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Multiplier set</span>
<select
value={selectedSetId}
onChange={(e) => {
setSelectedSetId(e.target.value);
setShowPreview(false);
}}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
>
{sets.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.rules.length} rules){s.isDefault ? " *" : ""}
</option>
))}
</select>
</label>
<button
onClick={() => setShowPreview(!showPreview)}
disabled={!selectedSetId}
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{showPreview ? "Hide preview" : "Preview"}
</button>
<button
onClick={() => {
if (!selectedSetId) return;
if (confirm("This will update cost/bill rates and hours on matching demand lines. Continue?")) {
applyMutation.mutate({ estimateId, multiplierSetId: selectedSetId });
}
}}
disabled={!selectedSetId || applyMutation.isPending}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{applyMutation.isPending ? "Applying..." : "Apply multipliers"}
</button>
</div>
{applyMutation.error && (
<p className="mt-2 text-sm text-red-600">{applyMutation.error.message}</p>
)}
{/* Preview */}
{showPreview && previewQuery.data && (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
<span>{previewQuery.data.demandLineCount} demand lines</span>
<span>{previewQuery.data.ruleCount} rules</span>
<span className="font-semibold text-brand-700">
{previewQuery.data.linesChanged} line(s) would be adjusted
</span>
</div>
{previewQuery.data.linesChanged > 0 && (
<div className="rounded-xl bg-blue-50 p-3 text-sm text-blue-700">
Total cost: {formatCents(previewQuery.data.totalOriginalCostCents)} {"->"}{" "}
{formatCents(previewQuery.data.totalAdjustedCostCents)}
</div>
)}
{/* Per-line preview */}
{previewQuery.data.previews.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
Show all {previewQuery.data.previews.length} lines
</summary>
<div className="mt-2 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Chapter</th>
<th className="px-3 py-2 text-right font-medium">Cost rate</th>
<th className="px-3 py-2 text-right font-medium">Bill rate</th>
<th className="px-3 py-2 text-right font-medium">Hours</th>
<th className="pl-3 py-2 font-medium">Changes</th>
</tr>
</thead>
<tbody>
{previewQuery.data.previews.map((p, i) => (
<tr
key={p.demandLineId}
className={clsx(
"border-b border-gray-100",
p.hasChanges ? "bg-amber-50" : i % 2 === 0 ? "" : "bg-gray-50",
)}
>
<td className="py-1.5 pr-3 text-gray-900">{p.name}</td>
<td className="px-3 py-1.5 text-gray-500">{p.chapter ?? "\u2014"}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
{p.hasChanges && p.adjustedCostRateCents !== p.originalCostRateCents ? (
<>
<span className="line-through text-gray-400">{formatCents(p.originalCostRateCents)}</span>{" "}
{formatCents(p.adjustedCostRateCents)}
</>
) : (
formatCents(p.originalCostRateCents)
)}
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
{p.hasChanges && p.adjustedBillRateCents !== p.originalBillRateCents ? (
<>
<span className="line-through text-gray-400">{formatCents(p.originalBillRateCents)}</span>{" "}
{formatCents(p.adjustedBillRateCents)}
</>
) : (
formatCents(p.originalBillRateCents)
)}
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
{p.hasChanges && p.adjustedHours !== p.originalHours ? (
<>
<span className="line-through text-gray-400">{p.originalHours}h</span>{" "}
{p.adjustedHours}h
</>
) : (
`${p.originalHours}h`
)}
</td>
<td className="pl-3 py-1.5 text-xs text-gray-500">
{p.hasChanges ? p.appliedRules[0] ?? "" : "No change"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</div>
)}
{showPreview && previewQuery.isLoading && (
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
)}
</div>
);
}
@@ -0,0 +1,837 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { EstimateStatus } from "@planarchy/shared";
import { computeEvenSpread } from "@planarchy/engine";
import { isSpreadsheetFile } from "~/lib/excel.js";
import { parseScopeImport } from "~/lib/scopeImportParser.js";
import { clsx } from "clsx";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
import { trpc } from "~/lib/trpc/client.js";
const INPUT_CLS =
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
const SELECT_CLS = INPUT_CLS;
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
const STEP_LABELS = ["Setup", "Assumptions", "Scope", "Staffing", "Review"];
interface AssumptionRow {
id: string;
category: string;
key: string;
label: string;
value: string;
}
interface ScopeRow {
id: string;
sequenceNo: number;
scopeType: string;
name: string;
description: string;
}
interface DemandRow {
id: string;
name: string;
roleId: string | null;
resourceId: string | null;
hours: string;
chapter: string;
costRate: string;
billRate: string;
currency: string;
}
interface ProjectOption {
id: string;
shortCode: string;
name: string;
startDate?: string | Date | null;
endDate?: string | Date | null;
}
interface RoleOption {
id: string;
name: string;
}
interface ResourceOption {
id: string;
eid: string;
displayName: string;
chapter: string | null;
currency: string;
lcrCents: number;
ucrCents: number;
roleId: string | null;
federalState: string | null;
}
function makeAssumption(): AssumptionRow {
return {
id: crypto.randomUUID(),
category: "commercial",
key: "",
label: "",
value: "",
};
}
function makeScope(sequenceNo = 1): ScopeRow {
return {
id: crypto.randomUUID(),
sequenceNo,
scopeType: "SHOT",
name: "",
description: "",
};
}
function makeDemand(): DemandRow {
return {
id: crypto.randomUUID(),
name: "",
roleId: null,
resourceId: null,
hours: "8",
chapter: "",
costRate: "",
billRate: "",
currency: "EUR",
};
}
function toCents(value: string) {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return 0;
}
return Math.round(parsed * 100);
}
function toHours(value: string) {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return 0;
}
return parsed;
}
function formatMoney(cents: number, currency = "EUR") {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(cents / 100);
}
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
export function EstimateWizard({ onClose }: { onClose: () => void }) {
const [step, setStep] = useState(0);
const [name, setName] = useState("");
const [projectId, setProjectId] = useState<string | null>(null);
const [opportunityId, setOpportunityId] = useState("");
const [baseCurrency, setBaseCurrency] = useState("EUR");
const [status, setStatus] = useState<EstimateStatus>(EstimateStatus.DRAFT);
const [versionLabel, setVersionLabel] = useState("Initial");
const [versionNotes, setVersionNotes] = useState("");
const [assumptions, setAssumptions] = useState<AssumptionRow[]>([makeAssumption()]);
const [scopeItems, setScopeItems] = useState<ScopeRow[]>([makeScope(1)]);
const [demandLines, setDemandLines] = useState<DemandRow[]>([makeDemand()]);
const [error, setError] = useState<string | null>(null);
const [scopeImportWarnings, setScopeImportWarnings] = useState<string[]>([]);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils();
const projectsQuery = trpc.project.list.useQuery({ limit: 200 }, { staleTime: 60_000 });
const rolesQuery = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
const resourcesQuery = trpc.resource.list.useQuery(
{ limit: 500, includeRoles: true, isActive: true },
{ staleTime: 60_000 },
);
const createMutation = trpc.estimate.create.useMutation({
onSuccess: async () => {
await utils.estimate.list.invalidate();
onClose();
},
onError: (mutationError) => {
setError(mutationError.message);
},
});
const projectRows = (projectsQuery.data?.projects ?? []) as unknown as ProjectOption[];
const roleRows = (rolesQuery.data ?? []) as unknown as RoleOption[];
const resourceRows = (resourcesQuery.data?.resources ?? []) as unknown as ResourceOption[];
const projects: ProjectOption[] = projectRows.map((project) => ({
id: project.id,
shortCode: project.shortCode,
name: project.name,
}));
const roles: RoleOption[] = roleRows.map((role) => ({
id: role.id,
name: role.name,
}));
const resources: ResourceOption[] = resourceRows.map((resource) => ({
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
chapter: resource.chapter,
currency: resource.currency,
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
roleId: resource.roleId,
federalState: resource.federalState,
}));
const selectedProject = projectId
? projects.find((project) => project.id === projectId) ?? null
: null;
const summary = useMemo(() => {
return demandLines.reduce(
(accumulator, line) => {
const hours = toHours(line.hours);
const costTotalCents = Math.round(hours * toCents(line.costRate));
const priceTotalCents = Math.round(hours * toCents(line.billRate));
return {
totalHours: accumulator.totalHours + hours,
totalCostCents: accumulator.totalCostCents + costTotalCents,
totalPriceCents: accumulator.totalPriceCents + priceTotalCents,
};
},
{ totalHours: 0, totalCostCents: 0, totalPriceCents: 0 },
);
}, [demandLines]);
const marginCents = summary.totalPriceCents - summary.totalCostCents;
const marginPercent = summary.totalPriceCents > 0
? Math.round((marginCents / summary.totalPriceCents) * 100)
: 0;
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
function updateAssumption(id: string, patch: Partial<AssumptionRow>) {
setAssumptions((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
}
function updateScopeItem(id: string, patch: Partial<ScopeRow>) {
setScopeItems((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
}
function updateDemandLine(id: string, patch: Partial<DemandRow>) {
setDemandLines((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
}
function applyResource(resourceId: string | null, demandLineId: string) {
const resource = resourceId
? resources.find((item) => item.id === resourceId) ?? null
: null;
updateDemandLine(demandLineId, {
resourceId,
name: resource?.displayName ?? "",
chapter: resource?.chapter ?? "",
currency: resource?.currency ?? baseCurrency,
costRate: resource ? (resource.lcrCents / 100).toFixed(2) : "",
billRate: resource ? (resource.ucrCents / 100).toFixed(2) : "",
roleId: resource?.roleId ?? null,
});
}
async function handleScopeImport(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
event.target.value = "";
if (!isSpreadsheetFile(file)) {
setScopeImportWarnings(["Unsupported file type. Please upload .xlsx, .xls, or .csv."]);
return;
}
try {
const result = await parseScopeImport(file);
setScopeImportWarnings(result.warnings);
if (result.rows.length > 0) {
const imported: ScopeRow[] = result.rows.map((row) => ({
id: crypto.randomUUID(),
sequenceNo: row.sequenceNo,
scopeType: row.scopeType,
name: row.name,
description: row.description,
}));
setScopeItems((current) => {
const nonEmpty = current.filter((item) => item.name.trim());
return [...nonEmpty, ...imported];
});
}
} catch {
setScopeImportWarnings(["Failed to parse the file. Please check the format."]);
}
}
function validateStep(targetStep: number) {
if (targetStep === 1 && !name.trim()) {
setError("Estimate name is required.");
return false;
}
setError(null);
return true;
}
function goNext() {
const nextStep = Math.min(step + 1, STEP_LABELS.length - 1);
if (!validateStep(nextStep)) return;
setStep(nextStep);
}
function goBack() {
setStep((current) => Math.max(current - 1, 0));
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!name.trim()) {
setError("Estimate name is required.");
setStep(0);
return;
}
const normalizedDemandLines = demandLines
.map((line, index) => {
const resource = line.resourceId
? resources.find((item) => item.id === line.resourceId) ?? null
: null;
const role = line.roleId
? roles.find((item) => item.id === line.roleId) ?? null
: null;
const hours = toHours(line.hours);
const costRateCents = toCents(line.costRate);
const billRateCents = toCents(line.billRate);
const displayName = line.name.trim() || resource?.displayName || role?.name || `Line ${index + 1}`;
return {
resourceId: line.resourceId ?? undefined,
roleId: line.roleId ?? undefined,
lineType: "LABOR",
name: displayName,
chapter: line.chapter || resource?.chapter || undefined,
hours,
days: hours > 0 ? Number((hours / 8).toFixed(2)) : undefined,
rateSource: resource ? "RESOURCE" : role ? "ROLE" : "MANUAL",
costRateCents,
billRateCents,
currency: line.currency || resource?.currency || baseCurrency,
costTotalCents: Math.round(hours * costRateCents),
priceTotalCents: Math.round(hours * billRateCents),
monthlySpread:
selectedProject?.startDate && selectedProject?.endDate && hours > 0
? computeEvenSpread({
totalHours: hours,
startDate: new Date(selectedProject.startDate),
endDate: new Date(selectedProject.endDate),
}).spread
: {},
staffingAttributes: {
linkedResource: resource ? true : false,
linkedRole: role ? true : false,
},
metadata: {},
};
})
.filter((line) => line.hours > 0);
const normalizedScopeItems = scopeItems
.map((item, index) => ({
sequenceNo: index + 1,
scopeType: item.scopeType.trim() || "SHOT",
name: item.name.trim(),
description: item.description.trim() || undefined,
technicalSpec: {},
sortOrder: index,
metadata: {},
}))
.filter((item) => item.name.length > 0);
const normalizedAssumptions = assumptions
.map((assumption, index) => ({
category: assumption.category.trim() || "general",
key: assumption.key.trim() || slugify(assumption.label) || `assumption_${index + 1}`,
label: assumption.label.trim(),
valueType: "text",
value: assumption.value.trim(),
sortOrder: index,
}))
.filter((assumption) => assumption.label.length > 0 && String(assumption.value).length > 0);
const seenResources = new Set<string>();
const resourceSnapshots = normalizedDemandLines.flatMap((line) => {
if (!line.resourceId) return [];
if (seenResources.has(line.resourceId)) return [];
seenResources.add(line.resourceId);
const resource = resources.find((item) => item.id === line.resourceId) ?? null;
if (!resource) return [];
return [
{
resourceId: resource.id,
sourceEid: resource.eid,
displayName: resource.displayName,
chapter: resource.chapter ?? undefined,
roleId: resource.roleId ?? undefined,
currency: resource.currency,
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
location: resource.federalState ?? undefined,
attributes: {},
},
];
});
createMutation.mutate({
projectId: projectId ?? undefined,
name: name.trim(),
opportunityId: opportunityId.trim() || undefined,
baseCurrency,
status,
versionLabel: versionLabel.trim() || undefined,
versionNotes: versionNotes.trim() || undefined,
assumptions: normalizedAssumptions,
scopeItems: normalizedScopeItems,
demandLines: normalizedDemandLines,
resourceSnapshots,
metrics: [],
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-950/45 p-4">
<div ref={panelRef} className="flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-[32px] bg-white shadow-2xl">
<div className="border-b border-gray-100 px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Wizard</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Create a connected estimate</h2>
<p className="mt-1 text-sm text-gray-500">
Rates, resource snapshots, and project linkage are pulled from existing Planarchy data.
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-500 transition hover:border-gray-300 hover:text-gray-700"
>
Close
</button>
</div>
<div className="mt-5 grid gap-2 md:grid-cols-5">
{STEP_LABELS.map((label, index) => (
<button
key={label}
type="button"
onClick={() => {
if (index <= step || validateStep(index)) {
setStep(index);
}
}}
className={clsx(
"rounded-2xl px-4 py-3 text-left transition",
index === step
? "bg-brand-600 text-white"
: index < step
? "bg-brand-50 text-brand-700"
: "bg-gray-50 text-gray-400",
)}
>
<span className="block text-xs uppercase tracking-wide">Step {index + 1}</span>
<span className="mt-1 block text-sm font-semibold">{label}</span>
</button>
))}
</div>
</div>
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="grid min-h-0 flex-1 gap-0 lg:grid-cols-[minmax(0,1.15fr),360px]">
<div className="min-h-0 overflow-y-auto px-6 py-6">
{step === 0 && (
<div className="space-y-5">
<div className="grid gap-5 md:grid-cols-2">
<div>
<label className={LABEL_CLS}>Estimate Name</label>
<input value={name} onChange={(event) => setName(event.target.value)} className={INPUT_CLS} placeholder="CGI Breakdown Q2 2026" />
</div>
<div>
<label className={LABEL_CLS}>Linked Project</label>
<ProjectCombobox value={projectId} onChange={setProjectId} placeholder="Link to project" />
</div>
<div>
<label className={LABEL_CLS}>Opportunity ID</label>
<input value={opportunityId} onChange={(event) => setOpportunityId(event.target.value)} className={INPUT_CLS} placeholder="Optional CRM or sales reference" />
</div>
<div>
<label className={LABEL_CLS}>Estimate Status</label>
<select value={status} onChange={(event) => setStatus(event.target.value as EstimateStatus)} className={SELECT_CLS}>
{Object.values(EstimateStatus).map((value) => (
<option key={value} value={value}>
{value.replace("_", " ")}
</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLS}>Base Currency</label>
<input value={baseCurrency} onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())} className={INPUT_CLS} maxLength={3} />
</div>
<div>
<label className={LABEL_CLS}>Version Label</label>
<input value={versionLabel} onChange={(event) => setVersionLabel(event.target.value)} className={INPUT_CLS} placeholder="Initial" />
</div>
</div>
<div>
<label className={LABEL_CLS}>Version Notes</label>
<textarea
value={versionNotes}
onChange={(event) => setVersionNotes(event.target.value)}
rows={5}
className={INPUT_CLS}
placeholder="Document assumptions, exclusions, or client comments."
/>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 p-5">
<p className="text-sm font-semibold text-gray-900">Live connection preview</p>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Project source</p>
<p className="mt-1 text-sm text-gray-700">
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "Not linked yet"}
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Live catalogs</p>
<p className="mt-1 text-sm text-gray-700">
{roles.length} roles, {resources.length} active resources available
</p>
</div>
</div>
</div>
</div>
)}
{step === 1 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Commercial and delivery assumptions</h3>
<p className="text-sm text-gray-500">These rows replace free-form spreadsheet notes with structured data.</p>
</div>
<button type="button" onClick={() => setAssumptions((current) => [...current, makeAssumption()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Add assumption
</button>
</div>
<div className="space-y-3">
{assumptions.map((row) => (
<div key={row.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[140px,1fr,1fr,1.2fr,auto]">
<input value={row.category} onChange={(event) => updateAssumption(row.id, { category: event.target.value })} className={INPUT_CLS} placeholder="Category" />
<input value={row.label} onChange={(event) => updateAssumption(row.id, { label: event.target.value })} className={INPUT_CLS} placeholder="Label" />
<input value={row.key} onChange={(event) => updateAssumption(row.id, { key: event.target.value })} className={INPUT_CLS} placeholder="Key (optional)" />
<input value={row.value} onChange={(event) => updateAssumption(row.id, { value: event.target.value })} className={INPUT_CLS} placeholder="Value" />
<button type="button" onClick={() => setAssumptions((current) => current.filter((item) => item.id !== row.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
Remove
</button>
</div>
))}
</div>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Scope breakdown</h3>
<p className="text-sm text-gray-500">Create structured work packages that can later evolve into versioned estimate scope.</p>
</div>
<div className="flex gap-2">
<label className="cursor-pointer rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Import XLSX
<input type="file" accept=".xlsx,.xls,.csv" onChange={handleScopeImport} className="hidden" />
</label>
<button type="button" onClick={() => setScopeItems((current) => [...current, makeScope(current.length + 1)])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Add scope row
</button>
</div>
</div>
{scopeImportWarnings.length > 0 && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
{scopeImportWarnings.map((warning, index) => (
<p key={index}>{warning}</p>
))}
</div>
)}
<div className="space-y-3">
{scopeItems.map((item, index) => (
<div key={item.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[90px,120px,1fr,1.2fr,auto]">
<input value={String(index + 1)} readOnly className={clsx(INPUT_CLS, "bg-gray-50 text-gray-500")} />
<input value={item.scopeType} onChange={(event) => updateScopeItem(item.id, { scopeType: event.target.value })} className={INPUT_CLS} placeholder="Type" />
<input value={item.name} onChange={(event) => updateScopeItem(item.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Name" />
<input value={item.description} onChange={(event) => updateScopeItem(item.id, { description: event.target.value })} className={INPUT_CLS} placeholder="Description" />
<button type="button" onClick={() => setScopeItems((current) => current.filter((row) => row.id !== item.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
Remove
</button>
</div>
))}
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Staffing and rate lines</h3>
<p className="text-sm text-gray-500">Selecting a resource pre-fills cost rate, sell rate, chapter, and role from live data.</p>
</div>
<button type="button" onClick={() => setDemandLines((current) => [...current, makeDemand()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Add staffing line
</button>
</div>
<div className="space-y-4">
{demandLines.map((line) => {
const resource = line.resourceId
? resources.find((item) => item.id === line.resourceId) ?? null
: null;
return (
<div key={line.id} className="rounded-3xl border border-gray-100 p-4">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<label className={LABEL_CLS}>Resource</label>
<ResourceCombobox value={line.resourceId} onChange={(resourceId) => applyResource(resourceId, line.id)} placeholder="Search resource" />
</div>
<div>
<label className={LABEL_CLS}>Role</label>
<select value={line.roleId ?? ""} onChange={(event) => updateDemandLine(line.id, { roleId: event.target.value || null })} className={SELECT_CLS}>
<option value="">Unassigned</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLS}>Line Name</label>
<input value={line.name} onChange={(event) => updateDemandLine(line.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Compositing, lighting, PM, ..." />
</div>
<div>
<label className={LABEL_CLS}>Chapter</label>
<input value={line.chapter} onChange={(event) => updateDemandLine(line.id, { chapter: event.target.value })} className={INPUT_CLS} placeholder="Auto-filled from resource when linked" />
</div>
<div>
<label className={LABEL_CLS}>Hours</label>
<input value={line.hours} onChange={(event) => updateDemandLine(line.id, { hours: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
<div>
<label className={LABEL_CLS}>Currency</label>
<input value={line.currency} onChange={(event) => updateDemandLine(line.id, { currency: event.target.value.toUpperCase() })} className={INPUT_CLS} maxLength={3} />
</div>
<div>
<label className={LABEL_CLS}>Cost Rate / h</label>
<input value={line.costRate} onChange={(event) => updateDemandLine(line.id, { costRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
<div>
<label className={LABEL_CLS}>Sell Rate / h</label>
<input value={line.billRate} onChange={(event) => updateDemandLine(line.id, { billRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
<div className="text-sm text-gray-600">
{resource ? `Linked to ${resource.displayName} (${resource.eid})` : "Manual line"}
</div>
<div className="flex flex-wrap gap-4 text-sm">
<span className="font-medium text-gray-700">
Cost {formatMoney(Math.round(toHours(line.hours) * toCents(line.costRate)), line.currency)}
</span>
<span className="font-medium text-gray-700">
Price {formatMoney(Math.round(toHours(line.hours) * toCents(line.billRate)), line.currency)}
</span>
</div>
<button type="button" onClick={() => setDemandLines((current) => current.filter((item) => item.id !== line.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
Remove
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{step === 4 && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900">Review</h3>
<p className="text-sm text-gray-500">The summary metrics below are recalculated from the demand rows and persisted on create.</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{summary.totalHours.toFixed(1)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalCostCents, baseCurrency)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalPriceCents, baseCurrency)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Margin</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(marginCents, baseCurrency)} ({marginPercent}%)
</p>
</div>
</div>
<div className="grid gap-5 lg:grid-cols-2">
<div className="rounded-3xl border border-gray-100 p-5">
<p className="text-sm font-semibold text-gray-900">Estimate envelope</p>
<dl className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex justify-between gap-4">
<dt>Name</dt>
<dd className="text-right text-gray-900">{name || "Untitled"}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Project</dt>
<dd className="text-right text-gray-900">{selectedProject ? `${selectedProject.shortCode}` : "Standalone"}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Status</dt>
<dd className="text-right text-gray-900">{status.replace("_", " ")}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Version</dt>
<dd className="text-right text-gray-900">{versionLabel || "Initial"}</dd>
</div>
</dl>
</div>
<div className="rounded-3xl border border-gray-100 p-5">
<p className="text-sm font-semibold text-gray-900">Connected records</p>
<dl className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex justify-between gap-4">
<dt>Assumptions</dt>
<dd className="text-right text-gray-900">{assumptions.filter((row) => row.label.trim()).length}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Scope items</dt>
<dd className="text-right text-gray-900">{scopeItems.filter((row) => row.name.trim()).length}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Demand lines</dt>
<dd className="text-right text-gray-900">{demandLines.filter((row) => toHours(row.hours) > 0).length}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Resource snapshots</dt>
<dd className="text-right text-gray-900">{new Set(demandLines.map((row) => row.resourceId).filter(Boolean)).size}</dd>
</div>
</dl>
</div>
</div>
</div>
)}
</div>
<aside className="border-t border-gray-100 bg-gray-50 px-6 py-6 lg:border-l lg:border-t-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">Dynamic summary</p>
<div className="mt-4 space-y-3">
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Project link</p>
<p className="mt-1 text-sm text-gray-800">
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "No linked project"}
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Resource-linked demand</p>
<p className="mt-1 text-sm text-gray-800">
{demandLines.filter((line) => line.resourceId).length} of {demandLines.length} rows tied to live resources
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Calculated totals</p>
<p className="mt-1 text-sm text-gray-800">{summary.totalHours.toFixed(1)} h</p>
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalCostCents, baseCurrency)} cost</p>
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalPriceCents, baseCurrency)} price</p>
</div>
</div>
{error && (
<div className="mt-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
</aside>
</div>
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
<button type="button" onClick={step === 0 ? onClose : goBack} className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
{step === 0 ? "Cancel" : "Back"}
</button>
{step < STEP_LABELS.length - 1 ? (
<button type="button" onClick={goNext} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
Next
</button>
) : (
<button type="submit" disabled={createMutation.isPending} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60">
{createMutation.isPending ? "Creating..." : "Create Estimate"}
</button>
)}
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,104 @@
import type {
EstimateDemandLineCalculationMetadata,
EstimateDemandLineMetadata,
EstimateDemandLineRateMode,
} from "@planarchy/shared";
interface ResourceRateSnapshotLike {
lcrCents: number;
ucrCents: number;
currency: string;
}
function parseRateMode(value: unknown): EstimateDemandLineRateMode | undefined {
return value === "resource" || value === "manual" ? value : undefined;
}
export function parseDemandLineMetadata(
metadata: Record<string, unknown> | null | undefined,
): EstimateDemandLineMetadata {
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
return {};
}
return metadata as EstimateDemandLineMetadata;
}
export function resolveDemandLineCalculationMetadata(options: {
resourceSnapshot?: ResourceRateSnapshotLike | null | undefined;
metadata?: Record<string, unknown> | null | undefined;
costRateCents: number;
billRateCents: number;
}): EstimateDemandLineCalculationMetadata {
const resourceSnapshot = options.resourceSnapshot;
const parsedMetadata = parseDemandLineMetadata(options.metadata);
const calculation =
typeof parsedMetadata.calculation === "object" &&
parsedMetadata.calculation !== null
? parsedMetadata.calculation
: undefined;
const costRateMode =
parseRateMode(calculation?.costRateMode) ??
(resourceSnapshot && options.costRateCents === resourceSnapshot.lcrCents
? "resource"
: "manual");
const billRateMode =
parseRateMode(calculation?.billRateMode) ??
(resourceSnapshot && options.billRateCents === resourceSnapshot.ucrCents
? "resource"
: "manual");
return {
costRateMode,
billRateMode,
totalMode: "computed",
liveCostRateCents: resourceSnapshot?.lcrCents ?? null,
liveBillRateCents: resourceSnapshot?.ucrCents ?? null,
liveCurrency: resourceSnapshot?.currency ?? null,
};
}
export function buildDemandLineMetadata(
metadata: Record<string, unknown> | null | undefined,
calculation: EstimateDemandLineCalculationMetadata,
): EstimateDemandLineMetadata {
return {
...parseDemandLineMetadata(metadata),
calculation,
};
}
export function getEffectiveDemandLineValues(options: {
resourceSnapshot?: ResourceRateSnapshotLike | null | undefined;
hours: number;
currency?: string | null;
defaultCurrency: string;
costRateCents: number;
billRateCents: number;
costRateMode: EstimateDemandLineRateMode;
billRateMode: EstimateDemandLineRateMode;
}) {
const effectiveCostRateCents =
options.costRateMode === "resource" && options.resourceSnapshot
? options.resourceSnapshot.lcrCents
: options.costRateCents;
const effectiveBillRateCents =
options.billRateMode === "resource" && options.resourceSnapshot
? options.resourceSnapshot.ucrCents
: options.billRateCents;
const currency =
((options.costRateMode === "resource" || options.billRateMode === "resource") &&
options.resourceSnapshot?.currency
? options.resourceSnapshot.currency
: options.currency) ||
options.resourceSnapshot?.currency ||
options.defaultCurrency;
return {
effectiveCostRateCents,
effectiveBillRateCents,
currency,
costTotalCents: Math.round(options.hours * effectiveCostRateCents),
priceTotalCents: Math.round(options.hours * effectiveBillRateCents),
};
}
@@ -0,0 +1,129 @@
"use client";
import type {
EstimateDemandLineMetadata,
EstimateExportArtifactPayload,
EstimateExportFormat,
EstimateStatus,
EstimateVersionStatus,
} from "@planarchy/shared";
export interface EstimateMetricView {
id: string;
key: string;
label: string;
valueDecimal: number;
valueCents?: number | null;
currency?: string | null;
}
export interface EstimateAssumptionView {
id: string;
category: string;
key: string;
label: string;
valueType: string;
value: unknown;
notes?: string | null;
}
export interface EstimateScopeItemView {
id: string;
sequenceNo: number;
scopeType: string;
packageCode?: string | null;
name: string;
description?: string | null;
frameCount?: number | null;
itemCount?: number | null;
unitMode?: string | null;
}
export interface EstimateDemandLineView {
id: string;
scopeItemId?: string | null;
roleId?: string | null;
resourceId?: string | null;
lineType: string;
name: string;
chapter?: string | null;
rateSource?: string | null;
hours: number;
currency: string;
costRateCents: number;
billRateCents: number;
costTotalCents: number;
priceTotalCents: number;
monthlySpread?: Record<string, number>;
metadata: EstimateDemandLineMetadata;
}
export interface EstimateResourceSnapshotView {
id: string;
resourceId?: string | null;
sourceEid?: string | null;
displayName: string;
chapter?: string | null;
roleId?: string | null;
currency: string;
lcrCents: number;
ucrCents: number;
fte?: number | null;
location?: string | null;
country?: string | null;
level?: string | null;
workType?: string | null;
attributes: Record<string, unknown>;
}
export interface EstimateExportView {
id: string;
fileName: string;
format: EstimateExportFormat;
payload?: EstimateExportArtifactPayload | Record<string, unknown> | null;
createdAt: Date;
}
export interface EstimateVersionView {
id: string;
versionNumber: number;
label?: string | null;
status: EstimateVersionStatus;
notes?: string | null;
lockedAt?: Date | null;
updatedAt: Date;
assumptions: EstimateAssumptionView[];
scopeItems: EstimateScopeItemView[];
demandLines: EstimateDemandLineView[];
resourceSnapshots: EstimateResourceSnapshotView[];
metrics: EstimateMetricView[];
exports: EstimateExportView[];
}
export interface EstimateWorkspaceView {
id: string;
name: string;
status: EstimateStatus;
projectId?: string | null;
opportunityId?: string | null;
baseCurrency: string;
updatedAt: Date;
project?: {
id: string;
name: string;
shortCode: string;
startDate?: string | Date | null;
endDate?: string | Date | null;
} | null;
versions: EstimateVersionView[];
}
export type WorkspaceTab =
| "overview"
| "assumptions"
| "scope"
| "staffing"
| "financials"
| "phasing"
| "versions"
| "exports";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,490 @@
"use client";
import { useMemo, useState } from "react";
import { clsx } from "clsx";
import {
compareEstimateVersions,
type VersionCompareInput,
type ChapterSubtotal,
type ResourceSnapshotDiff,
type ScopeItemDiff,
} from "@planarchy/engine";
import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js";
function formatMoney(cents: number, currency = "EUR") {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(cents / 100);
}
function formatDelta(value: number, formatter: (v: number) => string) {
const prefix = value > 0 ? "+" : "";
return `${prefix}${formatter(value)}`;
}
function formatHoursDelta(delta: number) {
const prefix = delta > 0 ? "+" : "";
return `${prefix}${delta.toFixed(1)} h`;
}
function versionToInput(v: EstimateVersionView): VersionCompareInput {
return {
label: v.label ?? null,
versionNumber: v.versionNumber,
demandLines: v.demandLines.map((l) => ({
id: l.id,
name: l.name,
hours: l.hours,
costRateCents: l.costRateCents,
billRateCents: l.billRateCents,
costTotalCents: l.costTotalCents,
priceTotalCents: l.priceTotalCents,
...(l.chapter !== undefined ? { chapter: l.chapter } : {}),
lineType: l.lineType,
})),
assumptions: v.assumptions.map((a) => ({
key: a.key,
label: a.label,
value: a.value,
})),
scopeItems: v.scopeItems.map((s) => ({
id: s.id,
name: s.name,
sequenceNo: s.sequenceNo,
scopeType: s.scopeType,
...(s.packageCode !== undefined ? { packageCode: s.packageCode } : {}),
...(s.description !== undefined ? { description: s.description } : {}),
...(s.frameCount !== undefined ? { frameCount: s.frameCount } : {}),
...(s.itemCount !== undefined ? { itemCount: s.itemCount } : {}),
})),
resourceSnapshots: v.resourceSnapshots.map((r) => ({
id: r.id,
...(r.resourceId !== undefined ? { resourceId: r.resourceId } : {}),
displayName: r.displayName,
...(r.chapter !== undefined ? { chapter: r.chapter } : {}),
currency: r.currency,
lcrCents: r.lcrCents,
ucrCents: r.ucrCents,
...(r.location !== undefined ? { location: r.location } : {}),
...(r.level !== undefined ? { level: r.level } : {}),
})),
};
}
const STATUS_ROW_STYLES = {
added: "bg-emerald-50",
removed: "bg-red-50",
changed: "bg-amber-50",
unchanged: "",
} as const;
const STATUS_BADGE_STYLES = {
added: "bg-emerald-100 text-emerald-700",
removed: "bg-red-100 text-red-700",
changed: "bg-amber-100 text-amber-700",
unchanged: "bg-gray-100 text-gray-500",
} as const;
export function VersionCompare({ versions }: { versions: EstimateVersionView[] }) {
const sorted = useMemo(
() => [...versions].sort((a, b) => b.versionNumber - a.versionNumber),
[versions],
);
const [aId, setAId] = useState<string>(sorted[1]?.id ?? sorted[0]?.id ?? "");
const [bId, setBId] = useState<string>(sorted[0]?.id ?? "");
const [hideUnchanged, setHideUnchanged] = useState(false);
const versionA = sorted.find((v) => v.id === aId);
const versionB = sorted.find((v) => v.id === bId);
const diff = useMemo(() => {
if (!versionA || !versionB || versionA.id === versionB.id) return null;
return compareEstimateVersions(versionToInput(versionA), versionToInput(versionB));
}, [versionA, versionB]);
if (sorted.length < 2) {
return (
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-8 text-center text-sm text-gray-500">
At least two versions are required to compare.
</div>
);
}
const filteredDemandDiffs = diff
? hideUnchanged
? diff.demandLineDiffs.filter((d) => d.status !== "unchanged")
: diff.demandLineDiffs
: [];
const filteredAssumptionDiffs = diff
? hideUnchanged
? diff.assumptionDiffs.filter((d) => d.status !== "unchanged")
: diff.assumptionDiffs
: [];
const filteredScopeDiffs = diff
? hideUnchanged
? diff.scopeItemDiffs.filter((d) => d.status !== "unchanged")
: diff.scopeItemDiffs
: [];
const filteredResourceDiffs = diff
? hideUnchanged
? diff.resourceSnapshotDiffs.filter((d) => d.status !== "unchanged")
: diff.resourceSnapshotDiffs
: [];
return (
<div className="space-y-6">
{/* Version selectors */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-4 text-base font-semibold text-gray-900">Compare versions</h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Base (A)</span>
<select
value={aId}
onChange={(e) => setAId(e.target.value)}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 focus:border-brand-400 focus:outline-none focus:ring-1 focus:ring-brand-400"
>
{sorted.map((v) => (
<option key={v.id} value={v.id}>
v{v.versionNumber} {v.label ? `\u2014 ${v.label}` : ""} ({v.status})
</option>
))}
</select>
</label>
<span className="pb-2 text-sm text-gray-400">vs</span>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Compare (B)</span>
<select
value={bId}
onChange={(e) => setBId(e.target.value)}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 focus:border-brand-400 focus:outline-none focus:ring-1 focus:ring-brand-400"
>
{sorted.map((v) => (
<option key={v.id} value={v.id}>
v{v.versionNumber} {v.label ? `\u2014 ${v.label}` : ""} ({v.status})
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 pb-2 text-sm text-gray-600">
<input
type="checkbox"
checked={hideUnchanged}
onChange={(e) => setHideUnchanged(e.target.checked)}
className="rounded border-gray-300"
/>
Hide unchanged
</label>
</div>
{aId === bId && (
<p className="mt-3 text-sm text-amber-600">Select two different versions to compare.</p>
)}
</div>
{diff && (
<>
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
<SummaryCard
label="Hours"
value={formatHoursDelta(diff.summary.totalHoursDelta)}
positive={diff.summary.totalHoursDelta <= 0}
/>
<SummaryCard
label="Cost"
value={formatDelta(diff.summary.totalCostDelta, (v) => formatMoney(v))}
positive={diff.summary.totalCostDelta <= 0}
/>
<SummaryCard
label="Price"
value={formatDelta(diff.summary.totalPriceDelta, (v) => formatMoney(v))}
positive={diff.summary.totalPriceDelta >= 0}
/>
<SummaryCard
label="Margin"
value={`${diff.summary.marginPercentB.toFixed(1)}% (${diff.summary.marginPercentDelta >= 0 ? "+" : ""}${diff.summary.marginPercentDelta.toFixed(1)}pp)`}
positive={diff.summary.marginPercentDelta >= 0}
/>
<SummaryCard label="Lines +" value={`+${diff.summary.linesAdded}`} positive />
<SummaryCard label="Lines -" value={`-${diff.summary.linesRemoved}`} positive={diff.summary.linesRemoved === 0} />
<SummaryCard label="Lines ~" value={String(diff.summary.linesChanged)} positive={diff.summary.linesChanged === 0} />
<SummaryCard label="Resources ~" value={String(diff.summary.resourceSnapshotsChanged)} positive={diff.summary.resourceSnapshotsChanged === 0} />
</div>
{/* Demand line diffs */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Demand lines</h3>
{filteredDemandDiffs.length === 0 ? (
<p className="py-4 text-center text-sm text-gray-400">
{hideUnchanged ? "No changes in demand lines." : "No demand lines to compare."}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">Hours (A)</th>
<th className="px-3 py-2 text-right font-medium">Hours (B)</th>
<th className="px-3 py-2 text-right font-medium">Hours delta</th>
<th className="px-3 py-2 text-right font-medium">Cost (A)</th>
<th className="px-3 py-2 text-right font-medium">Cost (B)</th>
<th className="px-3 py-2 text-right font-medium">Cost delta</th>
<th className="px-3 py-2 text-right font-medium">Price (A)</th>
<th className="px-3 py-2 text-right font-medium">Price (B)</th>
<th className="pl-3 py-2 text-right font-medium">Price delta</th>
</tr>
</thead>
<tbody>
{filteredDemandDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.hours.toFixed(1) ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.hours.toFixed(1) ?? "\u2014"}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.hoursDelta))}>
{d.hoursDelta != null ? formatHoursDelta(d.hoursDelta) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a ? formatMoney(d.a.costTotalCents) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b ? formatMoney(d.b.costTotalCents) : "\u2014"}
</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.costDelta))}>
{d.costDelta != null ? formatDelta(d.costDelta, (v) => formatMoney(v)) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a ? formatMoney(d.a.priceTotalCents) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b ? formatMoney(d.b.priceTotalCents) : "\u2014"}
</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(d.priceDelta))}>
{d.priceDelta != null ? formatDelta(d.priceDelta, (v) => formatMoney(v)) : "\u2014"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Assumption diffs */}
{(filteredAssumptionDiffs.length > 0 || !hideUnchanged) && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Assumptions</h3>
{filteredAssumptionDiffs.length === 0 ? (
<p className="py-4 text-center text-sm text-gray-400">
{hideUnchanged ? "No changes in assumptions." : "No assumptions to compare."}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Assumption</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Value (A)</th>
<th className="pl-3 py-2 font-medium">Value (B)</th>
</tr>
</thead>
<tbody>
{filteredAssumptionDiffs.map((d) => (
<tr key={d.key} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<td className="py-2 pr-3 font-medium text-gray-900">{d.label}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-gray-700">{formatAssumptionValue(d.aValue)}</td>
<td className="pl-3 py-2 text-gray-700">{formatAssumptionValue(d.bValue)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Chapter subtotals */}
{diff.chapterSubtotals.length > 0 && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">By chapter</h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Chapter</th>
<th className="px-3 py-2 text-right font-medium">Hours (A)</th>
<th className="px-3 py-2 text-right font-medium">Hours (B)</th>
<th className="px-3 py-2 text-right font-medium">Hours delta</th>
<th className="px-3 py-2 text-right font-medium">Cost (A)</th>
<th className="px-3 py-2 text-right font-medium">Cost (B)</th>
<th className="pl-3 py-2 text-right font-medium">Cost delta</th>
</tr>
</thead>
<tbody>
{diff.chapterSubtotals.map((ch) => (
<tr key={ch.chapter} className={clsx("border-b border-gray-100", ch.costDelta !== 0 ? "bg-amber-50" : "")}>
<td className="py-2 pr-3 font-medium text-gray-900">{ch.chapter}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursA.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursB.toFixed(1)}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(ch.hoursDelta))}>
{formatHoursDelta(ch.hoursDelta)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costA)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costB)}</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(ch.costDelta))}>
{formatDelta(ch.costDelta, (v) => formatMoney(v))}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Scope item diffs */}
{filteredScopeDiffs.length > 0 && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">
Scope items
{(diff.summary.scopeItemsAdded > 0 || diff.summary.scopeItemsRemoved > 0 || diff.summary.scopeItemsChanged > 0) && (
<span className="ml-2 text-sm font-normal text-gray-500">
{diff.summary.scopeItemsAdded > 0 && <span className="text-emerald-600">+{diff.summary.scopeItemsAdded}</span>}
{diff.summary.scopeItemsRemoved > 0 && <span className="ml-2 text-red-600">-{diff.summary.scopeItemsRemoved}</span>}
{diff.summary.scopeItemsChanged > 0 && <span className="ml-2 text-amber-600">~{diff.summary.scopeItemsChanged}</span>}
</span>
)}
</h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Type</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">Frames (A)</th>
<th className="px-3 py-2 text-right font-medium">Frames (B)</th>
<th className="px-3 py-2 text-right font-medium">Items (A)</th>
<th className="pl-3 py-2 text-right font-medium">Items (B)</th>
</tr>
</thead>
<tbody>
{filteredScopeDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
<td className="px-3 py-2 text-gray-600">{d.scopeType}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.frameCount ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.frameCount ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.itemCount ?? "\u2014"}</td>
<td className="pl-3 py-2 text-right tabular-nums text-gray-700">{d.b?.itemCount ?? "\u2014"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Resource snapshot diffs */}
{filteredResourceDiffs.length > 0 && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Resource rates</h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">LCR (A)</th>
<th className="px-3 py-2 text-right font-medium">LCR (B)</th>
<th className="px-3 py-2 text-right font-medium">UCR (A)</th>
<th className="px-3 py-2 text-right font-medium">UCR (B)</th>
<th className="px-3 py-2 font-medium">Location (A)</th>
<th className="pl-3 py-2 font-medium">Location (B)</th>
</tr>
</thead>
<tbody>
{filteredResourceDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<td className="py-2 pr-3 font-medium text-gray-900">{d.displayName}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.lcrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.lcrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.ucrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.ucrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-gray-600">{d.a?.location ?? "\u2014"}</td>
<td className="pl-3 py-2 text-gray-600">{d.b?.location ?? "\u2014"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
);
}
function SummaryCard({
label,
value,
positive,
}: {
label: string;
value: string;
positive: boolean;
}) {
return (
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-4 text-center shadow-sm">
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
<p className={clsx("mt-1 text-lg font-semibold tabular-nums", positive ? "text-emerald-700" : "text-red-700")}>
{value}
</p>
</div>
);
}
function deltaColor(delta: number | undefined): string {
if (delta == null || delta === 0) return "text-gray-400";
return delta > 0 ? "text-red-600" : "text-emerald-600";
}
function formatAssumptionValue(value: unknown): string {
if (value === undefined) return "\u2014";
if (value === null) return "null";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
@@ -0,0 +1,438 @@
"use client";
import { useMemo, useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
interface WeeklyPhasingViewProps {
estimateId: string;
canEdit: boolean;
}
type ViewMode = "by_line" | "by_chapter";
type PhasingPattern = "even" | "front_loaded" | "back_loaded";
function getDefaultDateRange(): { start: string; end: string } {
const now = new Date();
const start = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0);
const end = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, "0")}-${String(endDate.getDate()).padStart(2, "0")}`;
return { start, end };
}
function heatColor(hours: number, maxHours: number): string {
if (hours === 0 || maxHours === 0) return "";
const ratio = Math.min(hours / maxHours, 1);
if (ratio < 0.25) return "bg-blue-50";
if (ratio < 0.5) return "bg-blue-100";
if (ratio < 0.75) return "bg-blue-200";
return "bg-blue-300";
}
export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProps) {
const defaults = getDefaultDateRange();
const [startDate, setStartDate] = useState(defaults.start);
const [endDate, setEndDate] = useState(defaults.end);
const [pattern, setPattern] = useState<PhasingPattern>("even");
const [viewMode, setViewMode] = useState<ViewMode>("by_line");
const utils = trpc.useUtils();
const phasingQuery = trpc.estimate.getWeeklyPhasing.useQuery(
{ estimateId },
{ staleTime: 30_000 },
);
const generateMutation = trpc.estimate.generateWeeklyPhasing.useMutation({
onSuccess: () => {
void utils.estimate.getWeeklyPhasing.invalidate({ estimateId });
void utils.estimate.getById.invalidate({ id: estimateId });
},
});
const data = phasingQuery.data;
// Compute max hours for heat-map coloring
const maxHours = useMemo(() => {
if (!data?.hasPhasing) return 0;
let max = 0;
for (const line of data.lines) {
for (const h of Object.values(line.weeklyHours)) {
if (h > max) max = h;
}
}
return max;
}, [data]);
// Compute column totals
const columnTotals = useMemo(() => {
if (!data?.hasPhasing) return {};
const totals: Record<string, number> = {};
for (const line of data.lines) {
for (const [weekKey, hours] of Object.entries(line.weeklyHours)) {
totals[weekKey] = Math.round(((totals[weekKey] ?? 0) + hours) * 100) / 100;
}
}
return totals;
}, [data]);
// Compute chapter column totals
const chapterColumnTotals = useMemo(() => {
if (!data?.hasPhasing) return {};
const totals: Record<string, number> = {};
for (const chapterHours of Object.values(data.chapterAggregation)) {
for (const [weekKey, hours] of Object.entries(chapterHours)) {
totals[weekKey] = Math.round(((totals[weekKey] ?? 0) + hours) * 100) / 100;
}
}
return totals;
}, [data]);
// Compute max hours for chapter view
const maxChapterHours = useMemo(() => {
if (!data?.hasPhasing) return 0;
let max = 0;
for (const chapterHours of Object.values(data.chapterAggregation)) {
for (const h of Object.values(chapterHours)) {
if (h > max) max = h;
}
}
return max;
}, [data]);
const handleGenerate = () => {
generateMutation.mutate({
estimateId,
startDate,
endDate,
pattern,
});
};
// Use config dates from existing phasing if available
const effectiveStart = data?.hasPhasing ? data.config.startDate : startDate;
const effectiveEnd = data?.hasPhasing ? data.config.endDate : endDate;
return (
<div className="space-y-6">
{/* Header / Controls */}
<div className="rounded-3xl border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold text-gray-900">
Weekly Phasing (4Dispo)
</h3>
{canEdit && (
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Start Date
</label>
<input
type="date"
value={data?.hasPhasing ? effectiveStart : startDate}
onChange={(e) => setStartDate(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
End Date
</label>
<input
type="date"
value={data?.hasPhasing ? effectiveEnd : endDate}
onChange={(e) => setEndDate(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Pattern
</label>
<select
value={pattern}
onChange={(e) => setPattern(e.target.value as PhasingPattern)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="even">Even Distribution</option>
<option value="front_loaded">Front Loaded (60/40)</option>
<option value="back_loaded">Back Loaded (40/60)</option>
</select>
</div>
<button
type="button"
onClick={handleGenerate}
disabled={generateMutation.isPending}
className={clsx(
"rounded-lg px-4 py-2 text-sm font-medium text-white",
generateMutation.isPending
? "cursor-not-allowed bg-gray-400"
: "bg-sky-600 hover:bg-sky-700",
)}
>
{generateMutation.isPending ? "Generating..." : "Generate Phasing"}
</button>
</div>
)}
{generateMutation.isError && (
<p className="mt-2 text-sm text-red-600">
{generateMutation.error.message}
</p>
)}
{generateMutation.isSuccess && (
<p className="mt-2 text-sm text-emerald-600">
Phasing generated for {generateMutation.data.linesUpdated} demand
lines.
</p>
)}
</div>
{/* View toggle */}
{data?.hasPhasing && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setViewMode("by_line")}
className={clsx(
"rounded-lg px-3 py-1.5 text-sm font-medium",
viewMode === "by_line"
? "bg-sky-100 text-sky-700"
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
)}
>
By Line
</button>
<button
type="button"
onClick={() => setViewMode("by_chapter")}
className={clsx(
"rounded-lg px-3 py-1.5 text-sm font-medium",
viewMode === "by_chapter"
? "bg-sky-100 text-sky-700"
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
)}
>
By Chapter
</button>
</div>
)}
{/* Phasing Grid */}
{phasingQuery.isLoading && (
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
Loading phasing data...
</div>
)}
{data && !data.hasPhasing && (
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
No weekly phasing generated yet. Use the controls above to generate a
phasing distribution.
</div>
)}
{data?.hasPhasing && viewMode === "by_line" && (
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
Demand Line
</th>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
return (
<th
key={key}
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
>
{week.label}
</th>
);
})}
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
Total
</th>
</tr>
</thead>
<tbody>
{data.lines.map((line) => {
const lineTotal = Object.values(line.weeklyHours).reduce(
(sum, h) => sum + h,
0,
);
return (
<tr
key={line.id}
className="border-b border-gray-100 hover:bg-gray-50/50"
>
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
<div className="truncate max-w-[200px]" title={line.name}>
{line.name}
</div>
{line.chapter && (
<div className="text-xs text-gray-500">
{line.chapter}
</div>
)}
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const hours = line.weeklyHours[key] ?? 0;
return (
<td
key={key}
className={clsx(
"px-3 py-2 text-right tabular-nums",
heatColor(hours, maxHours),
)}
>
{hours > 0 ? hours.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
{lineTotal.toFixed(1)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
Total
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const total = columnTotals[key] ?? 0;
return (
<td
key={key}
className="px-3 py-3 text-right tabular-nums text-gray-900"
>
{total > 0 ? total.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
{Object.values(columnTotals)
.reduce((sum, h) => sum + h, 0)
.toFixed(1)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
{data?.hasPhasing && viewMode === "by_chapter" && (
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
Chapter
</th>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
return (
<th
key={key}
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
>
{week.label}
</th>
);
})}
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
Total
</th>
</tr>
</thead>
<tbody>
{Object.entries(data.chapterAggregation)
.sort(([a], [b]) => a.localeCompare(b))
.map(([chapter, weeklyHours]) => {
const chapterTotal = Object.values(weeklyHours).reduce(
(sum, h) => sum + h,
0,
);
return (
<tr
key={chapter}
className="border-b border-gray-100 hover:bg-gray-50/50"
>
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
{chapter}
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const hours = weeklyHours[key] ?? 0;
return (
<td
key={key}
className={clsx(
"px-3 py-2 text-right tabular-nums",
heatColor(hours, maxChapterHours),
)}
>
{hours > 0 ? hours.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
{chapterTotal.toFixed(1)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
Total
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const total = chapterColumnTotals[key] ?? 0;
return (
<td
key={key}
className="px-3 py-3 text-right tabular-nums text-gray-900"
>
{total > 0 ? total.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
{Object.values(chapterColumnTotals)
.reduce((sum, h) => sum + h, 0)
.toFixed(1)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
{/* Info about current phasing config */}
{data?.hasPhasing && data.config && (
<div className="rounded-3xl border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-600">
<span className="font-medium">Current phasing:</span>{" "}
{data.config.pattern.replace("_", " ")} distribution from{" "}
{data.config.startDate} to {data.config.endDate} across{" "}
{data.weeks.length} weeks, {data.lines.length} demand lines.
</p>
</div>
)}
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { signOut } from "next-auth/react";
import Link from "next/link";
import type { Route } from "next";
import { usePathname } from "next/navigation";
import { clsx } from "clsx";
import { Suspense, useState } from "react";
import { PreferencesModal } from "./PreferencesModal.js";
import { ThemeProvider } from "./ThemeProvider.js";
import { NotificationBell } from "../notifications/NotificationBell.js";
import { NavProgressBar } from "~/components/ui/NavProgressBar.js";
const allNavItems = [
{ href: "/dashboard", label: "Dashboard", icon: "📊", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/resources", label: "Resources", icon: "👥", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/projects", label: "Projects", icon: "📋", roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/estimates", label: "Estimates", icon: "🧮", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/allocations", label: "Allocations", icon: "📅", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/timeline", label: "Timeline", icon: "🗓️", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/staffing", label: "Staffing", icon: "🎯", roles: ["ADMIN", "MANAGER"] },
{ href: "/vacations", label: "Vacations", icon: "🏖️", roles: ["ADMIN", "MANAGER"] },
{ href: "/vacations/my", label: "My Vacations", icon: "🌴", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/roles", label: "Roles", icon: "🏷️", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/skills", label: "Skills Analytics", icon: "📈", roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/reports/chargeability", label: "Chargeability", icon: "📊", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
];
const adminNavItems = [
{ href: "/admin/blueprints", label: "Blueprints", icon: "🏗️" },
{ href: "/admin/countries", label: "Countries", icon: "🌍" },
{ href: "/admin/org-units", label: "Org Units", icon: "🏢" },
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: "📊" },
{ href: "/admin/clients", label: "Clients", icon: "🏦" },
{ href: "/admin/rate-cards", label: "Rate Cards", icon: "💲" },
{ href: "/admin/effort-rules", label: "Effort Rules", icon: "📐" },
{ href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: "📈" },
{ href: "/admin/management-levels", label: "Mgmt Levels", icon: "📶" },
{ href: "/admin/users", label: "Users", icon: "👤" },
{ href: "/admin/settings", label: "Settings", icon: "⚙️" },
{ href: "/admin/skill-import", label: "Skill Import", icon: "📥" },
];
const managerNavItems = [
{ href: "/admin/vacations", label: "Vacation Mgmt", icon: "🏖️" },
];
function Sidebar({ userRole }: { userRole: string }) {
const pathname = usePathname();
const [prefsOpen, setPrefsOpen] = useState(false);
const visibleNavItems = allNavItems.filter((item) => item.roles.includes(userRole));
const showAdmin = userRole === "ADMIN";
const showManagerSection = userRole === "ADMIN" || userRole === "MANAGER";
return (
<>
<nav className="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0">
{/* Logo */}
<div className="p-6 border-b border-gray-200 dark:border-gray-800">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-50">
Pl<span className="text-brand-600">anarchy</span>
</h1>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">Resource Planning</p>
</div>
{/* Nav links */}
<div className="flex-1 p-4 space-y-1 overflow-y-auto">
{visibleNavItems.map((item) => (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
pathname.startsWith(item.href)
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
)}
>
<span>{item.icon}</span>
{item.label}
</Link>
))}
{showManagerSection && (
<>
<div className="pt-3 pb-1">
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
<span className="px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{showAdmin ? "Admin" : "Management"}
</span>
</div>
</div>
{showAdmin && adminNavItems.map((item) => (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
pathname.startsWith(item.href)
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
)}
>
<span>{item.icon}</span>
{item.label}
</Link>
))}
{managerNavItems.map((item) => (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
pathname.startsWith(item.href)
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
)}
>
<span>{item.icon}</span>
{item.label}
</Link>
))}
</>
)}
</div>
{/* Bottom actions */}
<div className="p-4 border-t border-gray-200 dark:border-gray-800 space-y-1">
<div className="flex items-center gap-2 px-3 py-1">
<NotificationBell />
<span className="text-xs text-gray-400 dark:text-gray-500">Notifications</span>
</div>
<button
type="button"
onClick={() => setPrefsOpen(true)}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Preferences
</button>
<button
type="button"
onClick={() => void signOut({ callbackUrl: "/auth/signin" })}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign out
</button>
</div>
</nav>
{prefsOpen && <PreferencesModal onClose={() => setPrefsOpen(false)} />}
</>
);
}
export function AppShell({ children, userRole = "USER" }: { children: React.ReactNode; userRole?: string }) {
return (
<ThemeProvider>
<Suspense>
<NavProgressBar />
</Suspense>
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar userRole={userRole} />
<main className="flex-1 overflow-auto">{children}</main>
</div>
</ThemeProvider>
);
}
@@ -0,0 +1,258 @@
"use client";
import { useTheme } from "~/hooks/useTheme.js";
import type { AccentColor, ThemeMode } from "~/hooks/useTheme.js";
import { useAppPreferences, type HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
import { clsx } from "clsx";
interface PreferencesModalProps {
onClose: () => void;
}
const ACCENT_OPTIONS: { value: AccentColor; label: string; swatch: string }[] = [
{ value: "sky", label: "Sky", swatch: "#0284c7" },
{ value: "indigo", label: "Indigo", swatch: "#4f46e5" },
{ value: "violet", label: "Violet", swatch: "#7c3aed" },
{ value: "emerald", label: "Emerald", swatch: "#059669" },
{ value: "rose", label: "Rose", swatch: "#e11d48" },
{ value: "amber", label: "Amber", swatch: "#d97706" },
];
export function PreferencesModal({ onClose }: PreferencesModalProps) {
const { prefs, setMode, setAccent } = useTheme();
const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme } = useAppPreferences();
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end sm:items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-sm">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Preferences</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
>
×
</button>
</div>
<div className="px-6 py-5 space-y-6">
{/* Theme mode */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Appearance
</label>
<div className="grid grid-cols-2 gap-2">
{(["light", "dark"] as ThemeMode[]).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setMode(mode)}
className={clsx(
"flex items-center justify-center gap-2 px-4 py-3 rounded-xl border-2 text-sm font-medium transition-all",
prefs.mode === mode
? "border-brand-600 bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
)}
>
{mode === "light" ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
Light
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
Dark
</>
)}
</button>
))}
</div>
</div>
{/* Accent color */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Highlight Color
</label>
<div className="grid grid-cols-3 gap-2">
{ACCENT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setAccent(opt.value)}
className={clsx(
"flex items-center gap-2 px-3 py-2.5 rounded-xl border-2 text-xs font-medium transition-all",
prefs.accent === opt.value
? "border-brand-600 bg-brand-50 dark:bg-gray-700"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
)}
>
<span
className="w-4 h-4 rounded-full shrink-0"
style={{
backgroundColor: opt.swatch,
boxShadow: prefs.accent === opt.value ? `0 0 0 2px white, 0 0 0 4px ${opt.swatch}` : "none",
}}
/>
<span className="text-gray-700 dark:text-gray-300">{opt.label}</span>
{prefs.accent === opt.value && (
<svg className="w-3 h-3 ml-auto text-brand-600 shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M20.707 5.293a1 1 0 010 1.414l-11 11a1 1 0 01-1.414 0l-5-5a1 1 0 011.414-1.414L9 15.586 19.293 5.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</button>
))}
</div>
</div>
{/* Timeline defaults */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Timeline
</label>
{/* Display mode */}
<div className="mb-4">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Row display style</p>
<div className="grid grid-cols-3 gap-2">
{([
{ value: "strip", label: "Strips", desc: "Classic Gantt blocks" },
{ value: "bar", label: "Bars", desc: "Daily stacked hours" },
{ value: "heatmap", label: "Heatmap", desc: "Utilisation colours" },
] as const).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setTimelineDisplayMode(opt.value)}
className={clsx(
"flex flex-col items-center gap-1 px-3 py-2.5 rounded-xl border-2 text-xs font-medium transition-all",
appPrefs.timelineDisplayMode === opt.value
? "border-brand-600 bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
)}
>
{/* Miniature icon */}
{opt.value === "strip" ? (
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
<rect x="2" y="4" width="12" height="8" rx="2" fill="currentColor" opacity="0.6" />
<rect x="18" y="4" width="12" height="8" rx="2" fill="currentColor" opacity="0.4" />
</svg>
) : opt.value === "bar" ? (
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
<rect x="2" y="8" width="5" height="8" rx="1" fill="currentColor" opacity="0.6" />
<rect x="9" y="4" width="5" height="12" rx="1" fill="currentColor" opacity="0.5" />
<rect x="16" y="6" width="5" height="10" rx="1" fill="currentColor" opacity="0.7" />
<rect x="23" y="2" width="5" height="14" rx="1" fill="currentColor" opacity="0.4" />
</svg>
) : (
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
<rect x="0" y="0" width="8" height="16" fill="#22c55e" opacity="0.5" />
<rect x="8" y="0" width="8" height="16" fill="#eab308" opacity="0.5" />
<rect x="16" y="0" width="8" height="16" fill="#f97316" opacity="0.5" />
<rect x="24" y="0" width="8" height="16" fill="#ef4444" opacity="0.6" />
<rect x="2" y="4" width="10" height="8" rx="1" fill="white" opacity="0.7" />
<rect x="18" y="4" width="10" height="8" rx="1" fill="white" opacity="0.5" />
</svg>
)}
<span>{opt.label}</span>
<span className="font-normal text-[10px] opacity-70">{opt.desc}</span>
</button>
))}
</div>
</div>
{/* Heatmap color scheme */}
<div className="mb-4">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Heatmap color scale</p>
<div className="grid grid-cols-2 gap-2">
{([
{
value: "green-red" as HeatmapColorScheme,
label: "Green → Red",
stops: ["#22c55e","#84cc16","#facc15","#f97316","#ef4444"],
},
{
value: "blue-orange" as HeatmapColorScheme,
label: "Blue → Orange",
stops: ["#38bdf8","#3b82f6","#fbbf24","#f97316","#ef4444"],
},
{
value: "purple-yellow" as HeatmapColorScheme,
label: "Purple → Yellow",
stops: ["#a78bfa","#8b5cf6","#facc15","#f59e0b","#ef4444"],
},
{
value: "mono" as HeatmapColorScheme,
label: "Monochrome",
stops: ["#9ca3af","#6b7280","#4b5563","#374151","#111827"],
},
]).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setHeatmapColorScheme(opt.value)}
className={clsx(
"flex flex-col gap-1.5 px-2.5 py-2 rounded-xl border-2 text-xs font-medium transition-all text-left",
appPrefs.heatmapColorScheme === opt.value
? "border-brand-600 bg-brand-50 dark:bg-brand-900/30"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300",
)}
>
{/* Gradient swatch */}
<div className="flex rounded overflow-hidden w-full h-3">
{opt.stops.map((c) => (
<div key={c} className="flex-1" style={{ backgroundColor: c }} />
))}
</div>
<span className="text-gray-700 dark:text-gray-300">{opt.label}</span>
</button>
))}
</div>
</div>
<label className="flex items-start gap-3 cursor-pointer">
<div className="relative mt-0.5 flex-shrink-0">
<input
type="checkbox"
checked={appPrefs.hideCompletedProjects}
onChange={(e) => setHideCompletedProjects(e.target.checked)}
className="sr-only peer"
/>
<div className={clsx(
"w-9 h-5 rounded-full transition-colors",
appPrefs.hideCompletedProjects ? "bg-brand-600" : "bg-gray-200 dark:bg-gray-700",
)} />
<div className={clsx(
"absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform",
appPrefs.hideCompletedProjects ? "translate-x-4" : "translate-x-0",
)} />
</div>
<div>
<span className="text-sm text-gray-800 dark:text-gray-200 font-medium leading-tight block">
Hide completed &amp; cancelled projects
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
Can be overridden per session in the timeline filter panel.
</span>
</div>
</label>
</div>
{/* Preview note */}
<p className="text-xs text-gray-400 dark:text-gray-500">
Changes apply instantly and are saved in your browser.
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
/**
* Applies the stored theme to <html> immediately on mount (client only).
* Must be rendered inside the layout BEFORE the page content.
*/
export function ThemeProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
try {
const raw = localStorage.getItem("planarchy_theme");
if (!raw) return;
const prefs = JSON.parse(raw) as { mode?: string; accent?: string };
const html = document.documentElement;
if (prefs.mode === "dark") html.classList.add("dark");
else html.classList.remove("dark");
if (prefs.accent) html.setAttribute("data-accent", prefs.accent);
} catch {}
}, []);
return <>{children}</>;
}
@@ -0,0 +1,160 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
function relativeTime(date: Date): string {
const now = Date.now();
const diff = now - date.getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
return `${months}mo ago`;
}
export function NotificationBell() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils();
const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, {
refetchInterval: 30_000,
});
const { data: notifications = [] } = trpc.notification.list.useQuery(
{ limit: 20 },
{ enabled: open },
);
const markRead = trpc.notification.markRead.useMutation({
onSuccess: () => {
void utils.notification.unreadCount.invalidate();
void utils.notification.list.invalidate();
},
});
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleMarkAllRead() {
markRead.mutate({});
}
function handleMarkOne(id: string) {
markRead.mutate({ id });
}
return (
<div ref={ref} className="relative">
{/* Bell button */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="relative p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
aria-label="Notifications"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{unreadCount > 0 && (
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-red-500 rounded-full leading-none">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{/* Dropdown panel */}
{open && (
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-50">
Notifications
</span>
{unreadCount > 0 && (
<button
type="button"
onClick={handleMarkAllRead}
disabled={markRead.isPending}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline disabled:opacity-50"
>
Mark all read
</button>
)}
</div>
{/* List */}
<div className="max-h-96 overflow-y-auto divide-y divide-gray-50 dark:divide-gray-800">
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
No notifications yet
</div>
) : (
notifications.map((n) => {
const isUnread = n.readAt === null;
return (
<button
key={n.id}
type="button"
onClick={() => {
if (isUnread) handleMarkOne(n.id);
}}
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
isUnread ? "bg-blue-50/60 dark:bg-blue-900/10" : ""
}`}
>
<div className="flex items-start gap-2">
{isUnread && (
<span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
)}
<div className={isUnread ? "" : "ml-4"}>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug">
{n.title}
</p>
{n.body && (
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{n.body}
</p>
)}
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
{relativeTime(new Date(n.createdAt))}
</p>
</div>
</div>
</button>
);
})
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,129 @@
"use client";
import { clsx } from "clsx";
interface Warning {
level: string;
message: string;
}
interface BudgetStatusBarProps {
budgetCents: number;
allocatedCents: number;
confirmedCents: number;
proposedCents: number;
warnings: Warning[];
className?: string;
}
function formatEur(cents: number): string {
return (cents / 100).toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
function getConfirmedBarColor(utilizationPercent: number): string {
if (utilizationPercent > 95) return "bg-red-600";
if (utilizationPercent > 85) return "bg-orange-600";
if (utilizationPercent > 70) return "bg-yellow-600";
return "bg-green-600";
}
function getProposedBarColor(utilizationPercent: number): string {
if (utilizationPercent > 95) return "bg-red-300";
if (utilizationPercent > 85) return "bg-orange-300";
if (utilizationPercent > 70) return "bg-yellow-300";
return "bg-green-300";
}
function getWarningBadgeStyle(level: string): string {
if (level === "critical") return "bg-red-100 text-red-700 border border-red-200";
if (level === "warning") return "bg-orange-100 text-orange-700 border border-orange-200";
return "bg-yellow-100 text-yellow-700 border border-yellow-200";
}
export function BudgetStatusBar({
budgetCents,
allocatedCents,
confirmedCents,
proposedCents,
warnings,
className,
}: BudgetStatusBarProps) {
const utilizationPercent = budgetCents > 0 ? (allocatedCents / budgetCents) * 100 : 0;
const confirmedPercent = budgetCents > 0 ? (confirmedCents / budgetCents) * 100 : 0;
const proposedPercent = budgetCents > 0 ? (proposedCents / budgetCents) * 100 : 0;
const remainingCents = budgetCents - allocatedCents;
// Cap visual bar segments at 100% total
const cappedConfirmedPercent = Math.min(confirmedPercent, 100);
const cappedProposedPercent = Math.min(proposedPercent, Math.max(0, 100 - cappedConfirmedPercent));
const highestWarning = warnings.length > 0
? warnings.reduce((prev, curr) => {
const levels: Record<string, number> = { info: 0, warning: 1, critical: 2 };
return (levels[curr.level] ?? 0) > (levels[prev.level] ?? 0) ? curr : prev;
})
: null;
return (
<div className={clsx("space-y-1.5", className)}>
{/* Progress bar with stacked segments */}
<div className="relative h-3 bg-gray-100 rounded-full overflow-hidden">
{/* Confirmed segment */}
<div
className={clsx("absolute left-0 top-0 h-full transition-all", getConfirmedBarColor(utilizationPercent))}
style={{ width: `${cappedConfirmedPercent}%` }}
/>
{/* Proposed segment */}
<div
className={clsx("absolute top-0 h-full transition-all", getProposedBarColor(utilizationPercent))}
style={{ left: `${cappedConfirmedPercent}%`, width: `${cappedProposedPercent}%` }}
/>
</div>
{/* Labels row */}
<div className="flex items-center justify-between text-xs text-gray-600">
<span>
<span className="font-medium">{formatEur(allocatedCents)}</span>
{" / "}
<span>{formatEur(budgetCents)}</span>
{" "}
<span className="text-gray-400">({utilizationPercent.toFixed(1)}%)</span>
</span>
<div className="flex items-center gap-2">
{highestWarning && (
<span
className={clsx(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium",
getWarningBadgeStyle(highestWarning.level),
)}
>
{highestWarning.level === "critical" ? "⚠" : highestWarning.level === "warning" ? "!" : "i"}
{warnings.length > 1 ? `${warnings.length} warnings` : "Warning"}
</span>
)}
<span className={clsx("font-medium", remainingCents < 0 ? "text-red-600" : "text-gray-700")}>
{remainingCents >= 0 ? `${formatEur(remainingCents)} left` : `${formatEur(Math.abs(remainingCents))} over`}
</span>
</div>
</div>
{/* Legend */}
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1">
<span className={clsx("inline-block w-2.5 h-2.5 rounded-sm", getConfirmedBarColor(utilizationPercent))} />
Confirmed {formatEur(confirmedCents)}
</span>
<span className="flex items-center gap-1">
<span className={clsx("inline-block w-2.5 h-2.5 rounded-sm", getProposedBarColor(utilizationPercent))} />
Proposed {formatEur(proposedCents)}
</span>
</div>
</div>
);
}
@@ -0,0 +1,158 @@
"use client";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
import { BudgetStatusBar } from "./BudgetStatusBar.js";
interface BudgetStatusCardProps {
projectId: string;
}
function formatEur(cents: number): string {
return (cents / 100).toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
function WarningIcon({ level }: { level: string }) {
if (level === "critical") {
return (
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
);
}
if (level === "warning") {
return (
<svg className="w-4 h-4 text-orange-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
);
}
return (
<svg className="w-4 h-4 text-blue-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
);
}
function getWarningRowStyle(level: string): string {
if (level === "critical") return "bg-red-50 dark:bg-red-900/30 border border-red-100 dark:border-red-800 text-red-800 dark:text-red-400";
if (level === "warning") return "bg-orange-50 dark:bg-orange-900/30 border border-orange-100 dark:border-orange-800 text-orange-800 dark:text-orange-400";
return "bg-blue-50 dark:bg-blue-900/30 border border-blue-100 dark:border-blue-800 text-blue-800 dark:text-blue-400";
}
export function BudgetStatusCard({ projectId }: BudgetStatusCardProps) {
const { data, isLoading, error } = trpc.timeline.getBudgetStatus.useQuery({ projectId });
if (isLoading) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 animate-pulse">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-4" />
<div className="h-3 bg-gray-100 dark:bg-gray-700 rounded-full mb-3" />
<div className="grid grid-cols-4 gap-3">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-14 bg-gray-100 dark:bg-gray-700 rounded-lg" />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-red-200 dark:border-red-800 p-6">
<p className="text-sm text-red-600 dark:text-red-400">Failed to load budget status: {error.message}</p>
</div>
);
}
if (!data) return null;
const {
budgetCents,
allocatedCents,
confirmedCents,
proposedCents,
remainingCents,
winProbabilityWeightedCents,
warnings,
} = data;
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">Budget Status</h3>
{/* Progress bar */}
<BudgetStatusBar
budgetCents={budgetCents}
allocatedCents={allocatedCents}
confirmedCents={confirmedCents}
proposedCents={proposedCents}
warnings={warnings}
/>
{/* Numeric details grid */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Total Budget</p>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{formatEur(budgetCents)}</p>
</div>
<div className="bg-green-50 dark:bg-green-900/30 rounded-lg p-3">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Confirmed</p>
<p className="text-sm font-semibold text-green-800 dark:text-green-400">{formatEur(confirmedCents)}</p>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/30 rounded-lg p-3">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Proposed</p>
<p className="text-sm font-semibold text-yellow-800 dark:text-yellow-400">{formatEur(proposedCents)}</p>
</div>
<div className={clsx("rounded-lg p-3", remainingCents < 0 ? "bg-red-50 dark:bg-red-900/30" : "bg-blue-50 dark:bg-blue-900/30")}>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Remaining</p>
<p className={clsx("text-sm font-semibold", remainingCents < 0 ? "text-red-800 dark:text-red-400" : "text-blue-800 dark:text-blue-400")}>
{formatEur(remainingCents)}
</p>
</div>
</div>
{/* Win-probability weighted amount */}
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 border-t border-gray-100 dark:border-gray-700 pt-3">
<span className="text-gray-400 dark:text-gray-500">Win-probability weighted cost:</span>
<span className="font-medium text-gray-800 dark:text-gray-100">{formatEur(winProbabilityWeightedCents)}</span>
</div>
{/* Warnings list */}
{warnings.length > 0 && (
<div className="space-y-2 border-t border-gray-100 dark:border-gray-700 pt-3">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Warnings</p>
{warnings.map((warning, idx) => (
<div
key={idx}
className={clsx(
"flex items-start gap-2 rounded-lg px-3 py-2 text-sm",
getWarningRowStyle(warning.level),
)}
>
<WarningIcon level={warning.level} />
<span>{warning.message}</span>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,544 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { OrderType, AllocationType, ProjectStatus } from "@planarchy/shared";
import type { Project } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
const ORDER_TYPE_OPTIONS = [
{ value: "BD", label: "BD" },
{ value: "CHARGEABLE", label: "Chargeable" },
{ value: "INTERNAL", label: "Internal" },
{ value: "OVERHEAD", label: "Overhead" },
] as const;
const ALLOCATION_TYPE_OPTIONS = [
{ value: "INT", label: "INT" },
{ value: "EXT", label: "EXT" },
] as const;
const STATUS_OPTIONS = [
{ value: "DRAFT", label: "Draft" },
{ value: "ACTIVE", label: "Active" },
{ value: "ON_HOLD", label: "On Hold" },
{ value: "COMPLETED", label: "Completed" },
{ value: "CANCELLED", label: "Cancelled" },
] as const;
function formatDateForInput(date: Date | string): string {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
interface FormState {
shortCode: string;
name: string;
orderType: string;
allocationType: string;
winProbability: string;
budgetEur: string;
startDate: string;
endDate: string;
status: string;
responsiblePerson: string;
utilizationCategoryId: string;
clientId: string;
}
function getDefaultForm(): FormState {
const today = formatDateForInput(new Date());
return {
shortCode: "",
name: "",
orderType: "CHARGEABLE",
allocationType: "INT",
winProbability: "100",
budgetEur: "",
startDate: today,
endDate: today,
status: "DRAFT",
responsiblePerson: "",
utilizationCategoryId: "",
clientId: "",
};
}
function projectToForm(project: Project): FormState {
return {
shortCode: project.shortCode,
name: project.name,
orderType: project.orderType,
allocationType: project.allocationType,
winProbability: String(project.winProbability),
budgetEur: String(Math.round(project.budgetCents) / 100),
startDate: formatDateForInput(project.startDate),
endDate: formatDateForInput(project.endDate),
status: project.status,
responsiblePerson: project.responsiblePerson ?? "",
utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
clientId: (project as unknown as { clientId?: string | null }).clientId ?? "",
};
}
interface ProjectModalProps {
project?: Project | null;
onClose: () => void;
}
export function ProjectModal({ project, onClose }: ProjectModalProps) {
const isEdit = !!project;
const utils = trpc.useUtils();
const [form, setForm] = useState<FormState>(() =>
project ? projectToForm(project) : getDefaultForm(),
);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const { data: utilizationCategories } = trpc.utilizationCategory.list.useQuery(undefined, { staleTime: 60_000 });
const { data: clientList } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
// @ts-ignore TS2589: tRPC infers union type too deeply for CreateProjectSchema with .refine()
const createMutation = trpc.project.create.useMutation({
onSuccess: async () => {
await utils.project.list.invalidate();
onClose();
},
onError: (err) => {
setServerError(err.message);
},
});
const updateMutation = trpc.project.update.useMutation({
onSuccess: async () => {
await utils.project.list.invalidate();
onClose();
},
onError: (err) => {
setServerError(err.message);
},
});
const isLoading = createMutation.isPending || updateMutation.isPending;
// Close on Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
setErrors((prev) => ({ ...prev, [key]: undefined }));
setServerError(null);
}
function validate(): boolean {
const newErrors: Partial<Record<keyof FormState, string>> = {};
if (!isEdit && !form.shortCode.trim()) {
newErrors.shortCode = "Short code is required.";
} else if (!isEdit && !/^[A-Z0-9_-]+$/.test(form.shortCode.trim())) {
newErrors.shortCode = "Must be uppercase alphanumeric (A-Z, 0-9, _, -).";
}
if (!form.name.trim()) {
newErrors.name = "Name is required.";
}
const winProb = Number(form.winProbability);
if (isNaN(winProb) || winProb < 0 || winProb > 100) {
newErrors.winProbability = "Must be between 0 and 100.";
}
const budget = parseFloat(form.budgetEur);
if (isNaN(budget) || budget < 0) {
newErrors.budgetEur = "Must be a positive number.";
}
if (!form.startDate) {
newErrors.startDate = "Start date is required.";
}
if (!form.endDate) {
newErrors.endDate = "End date is required.";
} else if (form.startDate && form.endDate < form.startDate) {
newErrors.endDate = "End date must be on or after start date.";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
const budgetCents = Math.round(parseFloat(form.budgetEur) * 100);
const winProbability = Number(form.winProbability);
if (isEdit && project) {
updateMutation.mutate({
id: project.id,
data: {
name: form.name.trim(),
orderType: form.orderType as unknown as OrderType,
allocationType: form.allocationType as unknown as AllocationType,
winProbability,
budgetCents,
startDate: new Date(form.startDate),
endDate: new Date(form.endDate),
status: form.status as unknown as ProjectStatus,
responsiblePerson: form.responsiblePerson.trim() || undefined,
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
...(form.clientId ? { clientId: form.clientId } : {}),
},
});
} else {
createMutation.mutate({
shortCode: form.shortCode.trim(),
name: form.name.trim(),
orderType: form.orderType as unknown as OrderType,
allocationType: form.allocationType as unknown as AllocationType,
winProbability,
budgetCents,
startDate: new Date(form.startDate),
endDate: new Date(form.endDate),
status: form.status as unknown as ProjectStatus,
staffingReqs: [],
dynamicFields: {},
responsiblePerson: form.responsiblePerson.trim() || undefined,
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
...(form.clientId ? { clientId: form.clientId } : {}),
});
}
}
const inputClass =
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
const inputErrorClass =
"w-full px-3 py-2 border border-red-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-400 text-sm dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-xl mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isEdit ? "Edit Project" : "New Project"}
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} noValidate>
<div className="px-6 py-5 space-y-6">
{serverError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg px-4 py-3 text-sm text-red-700 dark:text-red-400">
{serverError}
</div>
)}
{/* Section 1: Identity */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Identity
</legend>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass} htmlFor="shortCode">
Chargecode <span className="text-red-500">*</span>
</label>
<input
id="shortCode"
type="text"
value={form.shortCode}
onChange={(e) => setField("shortCode", e.target.value.toUpperCase())}
disabled={isEdit}
placeholder="PRJ-001"
className={
isEdit
? `${inputClass} bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed`
: errors.shortCode
? inputErrorClass
: inputClass
}
/>
{errors.shortCode && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.shortCode}</p>
)}
</div>
<div>
<label className={labelClass} htmlFor="name">
Name <span className="text-red-500">*</span>
</label>
<input
id="name"
type="text"
value={form.name}
onChange={(e) => setField("name", e.target.value)}
placeholder="Project name"
className={errors.name ? inputErrorClass : inputClass}
/>
{errors.name && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.name}</p>
)}
</div>
</div>
</fieldset>
{/* Section 2: Classification */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Classification
</legend>
<div className="grid grid-cols-3 gap-4">
<div>
<label className={labelClass} htmlFor="orderType">
Order Type
</label>
<select
id="orderType"
value={form.orderType}
onChange={(e) => setField("orderType", e.target.value)}
className={inputClass}
>
{ORDER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="allocationType">
Allocation
</label>
<select
id="allocationType"
value={form.allocationType}
onChange={(e) => setField("allocationType", e.target.value)}
className={inputClass}
>
{ALLOCATION_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="winProbability">
Win Probability %
</label>
<input
id="winProbability"
type="number"
min={0}
max={100}
value={form.winProbability}
onChange={(e) => setField("winProbability", e.target.value)}
className={errors.winProbability ? inputErrorClass : inputClass}
/>
{errors.winProbability && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.winProbability}</p>
)}
</div>
</div>
</fieldset>
{/* Section: Categorization */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Categorization
</legend>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass} htmlFor="utilizationCategoryId">
Utilization Category
</label>
<select
id="utilizationCategoryId"
value={form.utilizationCategoryId}
onChange={(e) => setField("utilizationCategoryId", e.target.value)}
className={inputClass}
>
<option value=""> Not specified </option>
{(utilizationCategories ?? []).map((cat) => (
<option key={cat.id} value={cat.id}>
{(cat as unknown as { code: string }).code} {(cat as unknown as { name: string }).name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="clientId">
Client
</label>
<select
id="clientId"
value={form.clientId}
onChange={(e) => setField("clientId", e.target.value)}
className={inputClass}
>
<option value=""> Not specified </option>
{(clientList ?? []).map((c) => (
<option key={c.id} value={c.id}>
{(c as unknown as { name: string }).name}
{(c as unknown as { code: string | null }).code ? ` [${(c as unknown as { code: string }).code}]` : ""}
</option>
))}
</select>
</div>
</div>
</fieldset>
{/* Section 3: Timeline & Budget */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Timeline &amp; Budget
</legend>
<div className="grid grid-cols-3 gap-4">
<div>
<label className={labelClass} htmlFor="startDate">
Start Date
</label>
<DateInput
id="startDate"
value={form.startDate}
onChange={(v) => setField("startDate", v)}
className={errors.startDate ? inputErrorClass : inputClass}
/>
{errors.startDate && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.startDate}</p>
)}
</div>
<div>
<label className={labelClass} htmlFor="endDate">
End Date
</label>
<DateInput
id="endDate"
value={form.endDate}
min={form.startDate}
onChange={(v) => setField("endDate", v)}
className={errors.endDate ? inputErrorClass : inputClass}
/>
{errors.endDate && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.endDate}</p>
)}
</div>
<div>
<label className={labelClass} htmlFor="budgetEur">
Budget ()
</label>
<input
id="budgetEur"
type="number"
min={0}
step="0.01"
value={form.budgetEur}
onChange={(e) => setField("budgetEur", e.target.value)}
placeholder="0.00"
className={errors.budgetEur ? inputErrorClass : inputClass}
/>
{errors.budgetEur && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.budgetEur}</p>
)}
</div>
</div>
</fieldset>
{/* Section 4: Status */}
<fieldset>
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Status
</legend>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass} htmlFor="status">
Status
</label>
<select
id="status"
value={form.status}
onChange={(e) => setField("status", e.target.value)}
className={inputClass}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="responsiblePerson">
Responsible Person
</label>
<input
id="responsiblePerson"
type="text"
value={form.responsiblePerson}
onChange={(e) => setField("responsiblePerson", e.target.value)}
placeholder="Name or EID"
className={inputClass}
/>
</div>
</div>
</fieldset>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-xl">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 transition-colors"
>
{isLoading ? (isEdit ? "Saving…" : "Creating…") : isEdit ? "Save Changes" : "Create Project"}
</button>
</div>
</form>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
// @react-pdf/renderer runs server-side only — no "use client" directive
import { Document, Page, StyleSheet, Text, View } from "@react-pdf/renderer";
const styles = StyleSheet.create({
page: { padding: 30, fontFamily: "Helvetica", fontSize: 10 },
title: { fontSize: 18, marginBottom: 4, fontFamily: "Helvetica-Bold" },
subtitle: { fontSize: 11, color: "#6b7280", marginBottom: 20 },
table: { marginTop: 10 },
tableHeader: { flexDirection: "row", backgroundColor: "#f3f4f6", padding: "6 8", borderBottom: "1 solid #e5e7eb" },
tableRow: { flexDirection: "row", padding: "5 8", borderBottom: "1 solid #f3f4f6" },
col1: { width: "25%" },
col2: { width: "20%" },
col3: { width: "15%" },
col4: { width: "15%" },
col5: { width: "15%" },
col6: { width: "10%" },
headerText: { fontFamily: "Helvetica-Bold", color: "#374151", fontSize: 9 },
cellText: { color: "#4b5563", fontSize: 9 },
footer: { position: "absolute", bottom: 20, left: 30, right: 30, textAlign: "center", color: "#9ca3af", fontSize: 8 },
});
interface AllocationRow {
resourceName: string;
projectName: string;
role?: string | null;
startDate: string;
endDate: string;
hoursPerDay: number;
dailyCostCents: number;
}
interface AllocationReportProps {
title: string;
generatedAt: string;
rows: AllocationRow[];
}
export function AllocationReport({ title, generatedAt, rows }: AllocationReportProps) {
return (
<Document>
<Page size="A4" orientation="landscape" style={styles.page}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.subtitle}>Generated: {generatedAt}</Text>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.col1, styles.headerText]}>Resource</Text>
<Text style={[styles.col2, styles.headerText]}>Project</Text>
<Text style={[styles.col3, styles.headerText]}>Role</Text>
<Text style={[styles.col4, styles.headerText]}>Start</Text>
<Text style={[styles.col5, styles.headerText]}>End</Text>
<Text style={[styles.col6, styles.headerText]}>h/day</Text>
</View>
{rows.map((row, i) => (
<View key={i} style={[styles.tableRow, i % 2 === 1 ? { backgroundColor: "#f9fafb" } : {}]}>
<Text style={[styles.col1, styles.cellText]}>{row.resourceName}</Text>
<Text style={[styles.col2, styles.cellText]}>{row.projectName}</Text>
<Text style={[styles.col3, styles.cellText]}>{row.role ?? "—"}</Text>
<Text style={[styles.col4, styles.cellText]}>{row.startDate}</Text>
<Text style={[styles.col5, styles.cellText]}>{row.endDate}</Text>
<Text style={[styles.col6, styles.cellText]}>{row.hoursPerDay}h</Text>
</View>
))}
</View>
<Text style={styles.footer}>Planarchy · Confidential · {rows.length} allocations</Text>
</Page>
</Document>
);
}
@@ -0,0 +1,539 @@
"use client";
import React, { useState, useMemo, useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatMonth(key: string): string {
const [y, m] = key.split("-");
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return `${months[Number(m) - 1]} ${y}`;
}
function pct(ratio: number): string {
return `${Math.round(ratio * 100)}%`;
}
function chgColor(ratio: number, target: number): string {
if (ratio >= target) return "text-green-700 dark:text-green-400";
if (ratio >= target - 0.1) return "text-yellow-700 dark:text-yellow-400";
return "text-red-700 dark:text-red-400";
}
function barStyle(ratio: number, color: string): React.CSSProperties {
return {
background: `linear-gradient(to right, ${color} ${Math.min(ratio * 100, 100)}%, transparent ${Math.min(ratio * 100, 100)}%)`,
};
}
type GroupByField = "none" | "orgUnit" | "mgmtGroup" | "country";
interface ResourceRow {
id: string;
eid: string;
displayName: string;
fte: number;
country: string | null;
city: string | null;
orgUnit: string | null;
mgmtGroup: string | null;
mgmtLevel: string | null;
targetPct: number;
months: MonthData[];
}
interface MonthData {
monthKey: string;
sah: number;
chg: number;
bd: number;
mdi: number;
mo: number;
pdr: number;
absence: number;
unassigned: number;
}
interface GroupSummary {
label: string;
resources: ResourceRow[];
monthTotals: { monthKey: string; chg: number; target: number; gap: number; totalFte: number }[];
}
function computeGroupMonthTotals(
resources: ResourceRow[],
monthKeys: string[],
): GroupSummary["monthTotals"] {
const totalFte = resources.reduce((sum, r) => sum + r.fte, 0);
return monthKeys.map((key, idx) => {
if (totalFte === 0) return { monthKey: key, chg: 0, target: 0, gap: 0, totalFte: 0 };
const chg = resources.reduce((sum, r) => sum + r.fte * r.months[idx]!.chg, 0) / totalFte;
const target = resources.reduce((sum, r) => sum + r.fte * r.targetPct, 0) / totalFte;
return { monthKey: key, chg, target, gap: chg - target, totalFte };
});
}
// ─── Export ──────────────────────────────────────────────────────────────────
async function exportToExcel(
resources: ResourceRow[],
monthKeys: string[],
groupTotals: { monthKey: string; chg: number; target: number; gap: number; totalFte: number }[],
groups: GroupSummary[],
groupBy: GroupByField,
) {
const XLSX = await import("xlsx");
const wb = XLSX.utils.book_new();
// Build main data rows
const headers = ["Name", "EID", "FTE", "Target", "Country", "City", "Org Unit", "Mgmt Group", "Mgmt Level"];
for (const key of monthKeys) {
headers.push(`Chg ${formatMonth(key)}`);
headers.push(`BD ${formatMonth(key)}`);
headers.push(`MD&I ${formatMonth(key)}`);
headers.push(`M&O ${formatMonth(key)}`);
headers.push(`PD&R ${formatMonth(key)}`);
headers.push(`Abs ${formatMonth(key)}`);
headers.push(`Free ${formatMonth(key)}`);
headers.push(`SAH ${formatMonth(key)}`);
}
const rows: (string | number)[][] = [headers];
// Group total row
const totalRow: (string | number)[] = [
"GROUP TOTAL", "", groupTotals[0]?.totalFte ?? 0, groupTotals[0]?.target ?? 0,
"", "", "", "", "",
];
for (const gt of groupTotals) {
totalRow.push(Math.round(gt.chg * 100) / 100, 0, 0, 0, 0, 0, 0, 0);
}
rows.push(totalRow);
// Sub-group totals if grouping
if (groupBy !== "none") {
for (const g of groups) {
const subRow: (string | number)[] = [
` ${g.label} (${g.resources.length})`, "", g.monthTotals[0]?.totalFte ?? 0,
g.monthTotals[0]?.target ?? 0, "", "", "", "", "",
];
for (const mt of g.monthTotals) {
subRow.push(Math.round(mt.chg * 100) / 100, 0, 0, 0, 0, 0, 0, 0);
}
rows.push(subRow);
}
rows.push([]); // blank separator
}
// Resource rows
for (const r of resources) {
const row: (string | number)[] = [
r.displayName, r.eid, r.fte, Math.round(r.targetPct * 100) / 100,
r.country ?? "", r.city ?? "", r.orgUnit ?? "", r.mgmtGroup ?? "", r.mgmtLevel ?? "",
];
for (const m of r.months) {
row.push(
Math.round(m.chg * 1000) / 1000,
Math.round(m.bd * 1000) / 1000,
Math.round(m.mdi * 1000) / 1000,
Math.round(m.mo * 1000) / 1000,
Math.round(m.pdr * 1000) / 1000,
Math.round(m.absence * 1000) / 1000,
Math.round(m.unassigned * 1000) / 1000,
Math.round(m.sah * 100) / 100,
);
}
rows.push(row);
}
const ws = XLSX.utils.aoa_to_sheet(rows);
XLSX.utils.book_append_sheet(wb, ws, "Chargeability");
XLSX.writeFile(wb, `chargeability-report.xlsx`);
}
function exportToCsv(
resources: ResourceRow[],
monthKeys: string[],
) {
const headers = ["Name", "EID", "FTE", "Target", "Country", "City", "Org Unit", "Mgmt Group", "Mgmt Level"];
for (const key of monthKeys) {
headers.push(`Chg_${key}`, `BD_${key}`, `MDI_${key}`, `MO_${key}`, `PDR_${key}`, `Abs_${key}`, `Free_${key}`, `SAH_${key}`);
}
const lines = [headers.join(",")];
for (const r of resources) {
const vals = [
`"${r.displayName}"`, r.eid, r.fte, r.targetPct,
r.country ?? "", r.city ?? "", r.orgUnit ?? "", r.mgmtGroup ?? "", r.mgmtLevel ?? "",
];
for (const m of r.months) {
vals.push(
m.chg as unknown as string, m.bd as unknown as string, m.mdi as unknown as string,
m.mo as unknown as string, m.pdr as unknown as string, m.absence as unknown as string,
m.unassigned as unknown as string, m.sah as unknown as string,
);
}
lines.push(vals.join(","));
}
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "chargeability-report.csv";
a.click();
URL.revokeObjectURL(url);
}
// ─── Component ───────────────────────────────────────────────────────────────
const NOW = new Date();
const DEFAULT_START = `${NOW.getFullYear()}-${String(NOW.getMonth() + 1).padStart(2, "0")}`;
const DEFAULT_END_DATE = new Date(NOW.getFullYear(), NOW.getMonth() + 5, 1);
const DEFAULT_END = `${DEFAULT_END_DATE.getFullYear()}-${String(DEFAULT_END_DATE.getMonth() + 1).padStart(2, "0")}`;
export function ChargeabilityReportClient() {
const [startMonth, setStartMonth] = useState(DEFAULT_START);
const [endMonth, setEndMonth] = useState(DEFAULT_END);
const [orgUnitId, setOrgUnitId] = useState<string>("");
const [mgmtGroupId, setMgmtGroupId] = useState<string>("");
const [countryId, setCountryId] = useState<string>("");
const [groupBy, setGroupBy] = useState<GroupByField>("none");
const [expandedResource, setExpandedResource] = useState<string | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
// Filter dropdowns data
const orgUnitsQuery = trpc.orgUnit.list.useQuery();
const mgmtGroupsQuery = trpc.managementLevel.listGroups.useQuery();
const countriesQuery = trpc.country.list.useQuery();
const reportQuery = trpc.chargeabilityReport.getReport.useQuery(
{
startMonth,
endMonth,
...(orgUnitId ? { orgUnitId } : {}),
...(mgmtGroupId ? { managementLevelGroupId: mgmtGroupId } : {}),
...(countryId ? { countryId } : {}),
},
{ placeholderData: (prev) => prev },
);
const data = reportQuery.data;
const orgUnits = useMemo(() => {
const items = orgUnitsQuery.data ?? [];
return items.filter((u: { level: number }) => u.level === 7);
}, [orgUnitsQuery.data]);
// Group resources by selected dimension
const groups = useMemo((): GroupSummary[] => {
if (!data || groupBy === "none") return [];
const buckets = new Map<string, ResourceRow[]>();
for (const r of data.resources) {
let key: string;
switch (groupBy) {
case "orgUnit": key = r.orgUnit ?? "(No Org Unit)"; break;
case "mgmtGroup": key = r.mgmtGroup ?? "(No Mgmt Group)"; break;
case "country": key = r.country ?? "(No Country)"; break;
}
const list = buckets.get(key) ?? [];
list.push(r);
buckets.set(key, list);
}
return Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([label, resources]) => ({
label,
resources,
monthTotals: computeGroupMonthTotals(resources, data.monthKeys),
}));
}, [data, groupBy]);
const toggleGroup = useCallback((label: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}, []);
const handleExportExcel = useCallback(() => {
if (!data) return;
exportToExcel(data.resources, data.monthKeys, data.groupTotals, groups, groupBy);
}, [data, groups, groupBy]);
const handleExportCsv = useCallback(() => {
if (!data) return;
exportToCsv(data.resources, data.monthKeys);
}, [data]);
// ─── Render helpers ──────────────────────────────────────────────────────
function renderResourceRow(r: ResourceRow) {
return (
<tr
key={r.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
onClick={() => setExpandedResource(expandedResource === r.id ? null : r.id)}
>
<td className="sticky left-0 z-10 bg-white dark:bg-gray-900 px-3 py-2">
<div className="font-medium text-gray-900 dark:text-gray-100">{r.displayName}</div>
<div className="text-xs text-gray-400">
{[r.eid, r.country, r.city, r.orgUnit].filter(Boolean).join(" | ")}
</div>
</td>
<td className="px-2 py-2 text-center text-gray-600 dark:text-gray-400">{r.fte.toFixed(2)}</td>
<td className="px-2 py-2 text-center text-gray-600 dark:text-gray-400">{pct(r.targetPct)}</td>
{r.months.map((m) => (
<td key={m.monthKey} className="px-2 py-2 text-center">
<div
className={`rounded px-1 ${chgColor(m.chg, r.targetPct)}`}
style={barStyle(m.chg, m.chg >= r.targetPct ? "rgba(34,197,94,0.15)" : "rgba(239,68,68,0.1)")}
>
{pct(m.chg)}
</div>
</td>
))}
</tr>
);
}
function renderExpandedRow(r: ResourceRow) {
if (expandedResource !== r.id) return null;
return (
<tr key={`${r.id}-detail`} className="bg-gray-50 dark:bg-gray-800/30">
<td className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-800/30 px-3 py-2" colSpan={3}>
<div className="text-xs space-y-0.5 text-gray-500 dark:text-gray-400">
<div>Mgmt: {r.mgmtGroup ?? "—"} / {r.mgmtLevel ?? "—"}</div>
<div className="mt-1 grid grid-cols-7 gap-1 text-[10px]">
<span className="font-medium">Chg</span>
<span className="font-medium">BD</span>
<span className="font-medium">MD&I</span>
<span className="font-medium">M&O</span>
<span className="font-medium">PD&R</span>
<span className="font-medium">Abs</span>
<span className="font-medium">Free</span>
</div>
</div>
</td>
{r.months.map((m) => (
<td key={m.monthKey} className="px-2 py-2 text-center">
<div className="grid grid-cols-1 gap-0.5 text-[10px] text-gray-500 dark:text-gray-400">
<span className="text-green-600">{pct(m.chg)}</span>
<span>{pct(m.bd)}</span>
<span>{pct(m.mdi)}</span>
<span>{pct(m.mo)}</span>
<span>{pct(m.pdr)}</span>
<span className="text-orange-500">{pct(m.absence)}</span>
<span className="text-gray-400">{pct(m.unassigned)}</span>
</div>
</td>
))}
</tr>
);
}
function renderGroupTotalsRow(
label: string,
monthTotals: GroupSummary["monthTotals"],
count: number,
isOverall: boolean,
onClick?: () => void,
) {
const bg = isOverall
? "bg-brand-50 dark:bg-brand-900/20"
: "bg-indigo-50 dark:bg-indigo-900/20";
return (
<tr
className={`${bg} font-semibold ${onClick ? "cursor-pointer" : ""}`}
onClick={onClick}
>
<td className={`sticky left-0 z-10 ${bg} px-3 py-2 text-gray-900 dark:text-gray-100`}>
{onClick && <span className="mr-1 text-xs">{expandedGroups.has(label) ? "▾" : "▸"}</span>}
{label} ({count} resources)
</td>
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">
{monthTotals[0]?.totalFte.toFixed(1)}
</td>
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">
{monthTotals[0] ? pct(monthTotals[0].target) : "—"}
</td>
{monthTotals.map((mt) => (
<td key={mt.monthKey} className="px-2 py-2 text-center">
<div className={chgColor(mt.chg, mt.target)}>{pct(mt.chg)}</div>
{mt.gap !== 0 && (
<div className="text-xs text-gray-400">
{mt.gap > 0 ? "+" : ""}{pct(mt.gap)}
</div>
)}
</td>
))}
</tr>
);
}
// ─── Main render ─────────────────────────────────────────────────────────
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">
Chargeability Forecast
</h1>
{/* Export buttons */}
{data && data.resources.length > 0 && (
<div className="flex gap-2">
<button
onClick={handleExportExcel}
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
>
Export Excel
</button>
<button
onClick={handleExportCsv}
className="px-3 py-1.5 text-sm bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
Export CSV
</button>
</div>
)}
</div>
{/* Filters */}
<div className="flex flex-wrap gap-4 items-end bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">From</label>
<input
type="month"
value={startMonth}
onChange={(e) => setStartMonth(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">To</label>
<input
type="month"
value={endMonth}
onChange={(e) => setEndMonth(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Country</label>
<select
value={countryId}
onChange={(e) => setCountryId(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">All</option>
{(countriesQuery.data ?? []).map((c: { id: string; code: string; name: string }) => (
<option key={c.id} value={c.id}>{c.code} {c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Org Unit</label>
<select
value={orgUnitId}
onChange={(e) => setOrgUnitId(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">All</option>
{orgUnits.map((u: { id: string; name: string }) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Mgmt Level Group</label>
<select
value={mgmtGroupId}
onChange={(e) => setMgmtGroupId(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">All</option>
{(mgmtGroupsQuery.data ?? []).map((g: { id: string; name: string }) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Group By</label>
<select
value={groupBy}
onChange={(e) => { setGroupBy(e.target.value as GroupByField); setExpandedGroups(new Set()); }}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="none">No Grouping</option>
<option value="orgUnit">Org Unit</option>
<option value="mgmtGroup">Mgmt Level Group</option>
<option value="country">Country</option>
</select>
</div>
</div>
{/* Report table */}
{reportQuery.isLoading && !data ? (
<div className="text-center py-12 text-gray-500">Loading report...</div>
) : reportQuery.error ? (
<div className="text-center py-12 text-red-600">
Error: {reportQuery.error.message}
</div>
) : data && data.resources.length === 0 ? (
<div className="text-center py-12 text-gray-500">
No resources match the current filters.
</div>
) : data ? (
<div className="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded-lg">
<table className="min-w-full text-sm">
<thead>
<tr className="bg-gray-50 dark:bg-gray-800">
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400 min-w-[200px]">
Resource
</th>
<th className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 w-16">FTE</th>
<th className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 w-16">Target</th>
{data.monthKeys.map((key) => (
<th key={key} className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 min-w-[80px]">
{formatMonth(key)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{/* Overall group total */}
{renderGroupTotalsRow("Group Total", data.groupTotals, data.resources.length, true)}
{/* Grouped view */}
{groupBy !== "none" ? (
groups.map((g) => (
<React.Fragment key={g.label}>
{renderGroupTotalsRow(g.label, g.monthTotals, g.resources.length, false, () => toggleGroup(g.label))}
{expandedGroups.has(g.label) && g.resources.map((r) => (
<React.Fragment key={r.id}>
{renderResourceRow(r)}
{renderExpandedRow(r)}
</React.Fragment>
))}
</React.Fragment>
))
) : (
data.resources.map((r) => (
<React.Fragment key={r.id}>
{renderResourceRow(r)}
{renderExpandedRow(r)}
</React.Fragment>
))
)}
</tbody>
</table>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { formatDate } from "~/lib/format.js";
interface Props {
resourceId: string;
aiSummary: string | null;
aiSummaryUpdatedAt: Date | string | null;
onGenerated: () => void;
}
export function AiSummaryCard({ resourceId, aiSummary, aiSummaryUpdatedAt, onGenerated }: Props) {
const [localSummary, setLocalSummary] = useState<string | null>(aiSummary);
const [localUpdatedAt, setLocalUpdatedAt] = useState<Date | string | null>(aiSummaryUpdatedAt);
const [error, setError] = useState<string | null>(null);
const { canEdit } = usePermissions();
// Keep local state in sync if the parent refreshes with newer data
if (aiSummary && aiSummary !== localSummary) {
setLocalSummary(aiSummary);
setLocalUpdatedAt(aiSummaryUpdatedAt);
}
const generateMutation = trpc.resource.generateAiSummary.useMutation({
onSuccess: (data) => {
setError(null);
setLocalSummary(data.summary);
setLocalUpdatedAt(new Date());
onGenerated();
},
onError: (err) => {
setError(err.message ?? "Failed to generate summary");
},
});
const { data: aiConfigured } = trpc.settings.getAiConfigured.useQuery(undefined, {
staleTime: 5_000,
});
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-800">AI Profile Summary</h2>
{canEdit && aiConfigured?.configured && (
<button
type="button"
onClick={() => generateMutation.mutate({ resourceId })}
disabled={generateMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50 transition-colors"
>
{generateMutation.isPending ? (
<>
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Generating
</>
) : localSummary ? (
"Regenerate"
) : (
"Generate"
)}
</button>
)}
</div>
{error && (
<div className="mb-3 rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-xs text-red-700">
{error}
</div>
)}
{localSummary ? (
<>
<p className="text-sm text-gray-700 leading-relaxed">{localSummary}</p>
{localUpdatedAt && (
<p className="text-xs text-gray-400 mt-2">
Generated {formatDate(localUpdatedAt)}
</p>
)}
</>
) : (
<p className="text-sm text-gray-400 italic">
{!aiConfigured?.configured
? "AI not configured. Set up your API key in Admin → Settings."
: canEdit
? "No summary yet. Click Generate to create one."
: "No summary generated yet."}
</p>
)}
</div>
);
}
@@ -0,0 +1,196 @@
"use client";
import { useState } from "react";
import { FieldType } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
const INPUT_CLS =
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
interface Props {
selectedIds: string[];
fieldDefs: BlueprintFieldDefinition[];
onClose: () => void;
onSuccess: () => void;
}
export function BulkEditModal({ selectedIds, fieldDefs, onClose, onSuccess }: Props) {
// Track which fields are included in the bulk update + their new values
const [included, setIncluded] = useState<Set<string>>(new Set());
const [values, setValues] = useState<Record<string, unknown>>({});
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const mutation = trpc.resource.batchUpdateCustomFields.useMutation({
onSuccess: async () => {
await utils.resource.list.invalidate();
onSuccess();
onClose();
},
onError: (err) => setError(err.message),
});
function toggleInclude(key: string) {
setIncluded((prev) => {
const next = new Set(prev);
if (next.has(key)) { next.delete(key); } else { next.add(key); }
return next;
});
}
function setValue(key: string, value: unknown) {
setValues((prev) => ({ ...prev, [key]: value }));
}
function handleSave() {
setError(null);
const fields: Record<string, unknown> = {};
for (const key of included) {
fields[key] = values[key] ?? "";
}
if (Object.keys(fields).length === 0) {
setError("Select at least one field to update.");
return;
}
mutation.mutate({ ids: selectedIds, fields });
}
function handleBackdrop(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === e.currentTarget) onClose();
}
const editableFields = fieldDefs.filter((f) => !f.required || included.has(f.key));
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={handleBackdrop}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<div>
<h2 className="text-lg font-semibold text-gray-900">Bulk Edit Custom Fields</h2>
<p className="text-sm text-gray-500 mt-0.5">
Updating {selectedIds.length} resource{selectedIds.length !== 1 ? "s" : ""}
</p>
</div>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none" aria-label="Close">×</button>
</div>
<div className="px-6 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
{fieldDefs.length === 0 && (
<p className="text-sm text-gray-400 text-center py-6">No custom fields defined. Configure them in Admin Blueprints.</p>
)}
{fieldDefs.map((field) => (
<div key={field.key} className={`border rounded-lg p-3 transition-colors ${included.has(field.key) ? "border-brand-300 bg-brand-50" : "border-gray-200"}`}>
<label className="flex items-center gap-2 mb-2 cursor-pointer">
<input
type="checkbox"
checked={included.has(field.key)}
onChange={() => toggleInclude(field.key)}
className="rounded border-gray-300 text-brand-600"
/>
<span className="text-sm font-medium text-gray-700">{field.label}</span>
{field.required && <span className="text-xs text-red-500">required</span>}
</label>
{included.has(field.key) && (
<FieldInput
field={field}
value={values[field.key]}
onChange={(v) => setValue(field.key, v)}
/>
)}
</div>
))}
</div>
{error && (
<div className="mx-6 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
)}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<p className="text-xs text-gray-400">{included.size} field{included.size !== 1 ? "s" : ""} selected</p>
<div className="flex gap-3">
<button type="button" onClick={onClose} className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium">
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={mutation.isPending || included.size === 0}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{mutation.isPending ? "Saving…" : `Apply to ${selectedIds.length} resource${selectedIds.length !== 1 ? "s" : ""}`}
</button>
</div>
</div>
</div>
</div>
);
}
function FieldInput({ field, value, onChange }: { field: BlueprintFieldDefinition; value: unknown; onChange: (v: unknown) => void }) {
const str = value !== undefined && value !== null ? String(value) : "";
if (field.type === FieldType.BOOLEAN) {
return (
<select value={str} onChange={(e) => onChange(e.target.value === "true")} className={INPUT_CLS}>
<option value=""> select </option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
);
}
if (field.type === FieldType.SELECT && field.options) {
return (
<select value={str} onChange={(e) => onChange(e.target.value)} className={INPUT_CLS}>
<option value=""> select </option>
{field.options.map((o) => <option key={o.value} value={o.value}>{o.label || o.value}</option>)}
</select>
);
}
if (field.type === FieldType.NUMBER) {
return (
<input
type="number"
value={str}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : "")}
placeholder={field.placeholder}
className={INPUT_CLS}
/>
);
}
if (field.type === FieldType.DATE) {
return <input type="date" value={str} onChange={(e) => onChange(e.target.value)} className={INPUT_CLS} />;
}
if (field.type === FieldType.TEXTAREA) {
return (
<textarea
value={str}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className={`${INPUT_CLS} w-full resize-none`}
rows={3}
/>
);
}
return (
<input
type={field.type === FieldType.EMAIL ? "email" : field.type === FieldType.URL ? "url" : "text"}
value={str}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className={`${INPUT_CLS} w-full`}
/>
);
}
// Re-export for convenience
export type { Props as BulkEditModalProps };
@@ -0,0 +1,312 @@
"use client";
import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSpreadsheet, isSpreadsheetFile } from "~/lib/excel.js";
type ImportStage = "idle" | "preview" | "importing" | "done";
interface ImportResult {
total: number;
created: number;
updated: number;
errors: { row: number; message: string }[];
dryRun: boolean;
message?: string;
}
interface Props {
onClose: () => void;
}
export function ImportModal({ onClose }: Props) {
const [stage, setStage] = useState<ImportStage>("idle");
const [rows, setRows] = useState<Record<string, string>[]>([]);
const [fileName, setFileName] = useState<string>("");
const [fileError, setFileError] = useState<string>("");
const [result, setResult] = useState<ImportResult | null>(null);
const [dryRun, setDryRun] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const importMutation = trpc.importExport.importCSV.useMutation({
onSuccess: (data) => {
setResult(data);
setStage("done");
},
onError: (err) => {
setFileError(err.message);
setStage("preview");
},
});
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setFileError("");
setRows([]);
setResult(null);
if (!isSpreadsheetFile(file)) {
setFileError("Unsupported file type. Please upload an Excel (.xlsx, .xls) or CSV file.");
return;
}
setFileName(file.name);
try {
const parsed = await parseSpreadsheet(file);
setRows(parsed);
setStage("preview");
} catch (err) {
setFileError(err instanceof Error ? err.message : "Failed to parse file.");
}
}
function handleImport() {
if (rows.length === 0) return;
setStage("importing");
importMutation.mutate({
entityType: "resources",
rows,
dryRun,
});
}
function handleReset() {
setStage("idle");
setRows([]);
setFileName("");
setFileError("");
setResult(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
const previewHeaders = rows.length > 0 ? Object.keys(rows[0]!) : [];
const previewRows = rows.slice(0, 5);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Import Resources</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* File picker */}
{stage === "idle" && (
<div className="space-y-4">
<p className="text-sm text-gray-600">
Upload an Excel or CSV file to import resources. The first row must contain column headers
matching the resource fields (e.g.{" "}
<code className="px-1 py-0.5 bg-gray-100 rounded text-xs font-mono">
eid, displayName, email, chapter, lcrCents
</code>
).
</p>
<label className="block">
<span className="sr-only">Choose spreadsheet file</span>
<div
className="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-gray-300 rounded-xl cursor-pointer hover:border-brand-400 hover:bg-brand-50 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<svg className="w-10 h-10 text-gray-400 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-sm text-gray-500">Click to select Excel or CSV</p>
<p className="text-xs text-gray-400 mt-1">.xlsx, .xls, .csv supported</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
className="hidden"
onChange={handleFileChange}
/>
</label>
{fileError && (
<p className="text-sm text-red-600">{fileError}</p>
)}
</div>
)}
{/* Preview */}
{(stage === "preview" || stage === "importing") && rows.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900">{fileName}</p>
<p className="text-xs text-gray-500">{rows.length} row{rows.length !== 1 ? "s" : ""} parsed</p>
</div>
<button
type="button"
onClick={handleReset}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Choose different file
</button>
</div>
{previewHeaders.length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Preview (first {previewRows.length} of {rows.length} rows)
</p>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="text-xs w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{previewHeaders.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{previewRows.map((row, i) => (
<tr key={i} className="hover:bg-gray-50">
{previewHeaders.map((h) => (
<td key={h} className="px-3 py-2 text-gray-700 whitespace-nowrap max-w-[200px] truncate">
{row[h] ?? ""}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{rows.length > 5 && (
<p className="text-xs text-gray-400 mt-1">and {rows.length - 5} more rows</p>
)}
</div>
)}
{fileError && (
<p className="text-sm text-red-600">{fileError}</p>
)}
{/* Import options */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="dryRun"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="dryRun" className="text-sm text-gray-700">
Dry run (validate only, do not write to database)
</label>
</div>
</div>
)}
{/* Done */}
{stage === "done" && result && (
<div className="space-y-4">
<div className={`rounded-lg p-4 ${result.errors.length > 0 ? "bg-yellow-50 border border-yellow-200" : "bg-green-50 border border-green-200"}`}>
<p className="text-sm font-medium text-gray-900 mb-1">
{result.dryRun ? "Dry run complete" : "Import complete"}
</p>
<ul className="text-sm text-gray-700 space-y-0.5">
<li>Total rows: <strong>{result.total}</strong></li>
{!result.dryRun && (
<>
<li>Created: <strong>{result.created}</strong></li>
<li>Updated: <strong>{result.updated}</strong></li>
</>
)}
{result.message && <li>{result.message}</li>}
{result.errors.length > 0 && (
<li className="text-red-600">Errors: <strong>{result.errors.length}</strong></li>
)}
</ul>
</div>
{result.errors.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Errors</p>
<ul className="text-xs text-red-600 space-y-0.5 max-h-32 overflow-y-auto">
{result.errors.map((e, i) => (
<li key={i}>Row {e.row}: {e.message}</li>
))}
</ul>
</div>
)}
{result.dryRun && result.errors.length === 0 && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="dryRunConfirm"
checked={false}
onChange={() => {
setDryRun(false);
setStage("preview");
setResult(null);
}}
className="rounded border-gray-300"
/>
<label htmlFor="dryRunConfirm" className="text-sm text-gray-700 cursor-pointer">
Validation passed click here to run the actual import
</label>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Close
</button>
{(stage === "preview") && (
<button
type="button"
onClick={handleImport}
disabled={rows.length === 0 || importMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 transition-colors"
>
{dryRun ? "Validate" : "Import"} {rows.length} row{rows.length !== 1 ? "s" : ""}
</button>
)}
{stage === "importing" && (
<span className="px-4 py-2 text-sm text-gray-500 animate-pulse">Importing</span>
)}
{stage === "done" && (
<button
type="button"
onClick={handleReset}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Import another file
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,598 @@
"use client";
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";
import { ResourceModal } from "./ResourceModal.js";
import { SkillRadarChart } from "./SkillRadarChart.js";
import { AiSummaryCard } from "./AiSummaryCard.js";
import { SkillMatrixUpload } from "./SkillMatrixUpload.js";
import { usePermissions } from "~/hooks/usePermissions.js";
interface ResourceDetailProps {
resourceId: string;
}
const proficiencyLabel: Record<number, string> = {
1: "Beginner",
2: "Elementary",
3: "Intermediate",
4: "Advanced",
5: "Expert",
};
const proficiencyColor: Record<number, string> = {
1: "bg-gray-100 text-gray-600",
2: "bg-blue-50 text-blue-600",
3: "bg-brand-50 text-brand-700",
4: "bg-amber-50 text-amber-700",
5: "bg-green-50 text-green-700",
};
const vacationStatusColor: Record<string, string> = {
PENDING: "bg-yellow-100 text-yellow-700",
APPROVED: "bg-green-100 text-green-700",
REJECTED: "bg-red-100 text-red-700",
CANCELLED: "bg-gray-100 text-gray-500",
};
const allocationStatusColor: Record<string, string> = {
PROPOSED: "bg-gray-100 text-gray-600",
CONFIRMED: "bg-blue-100 text-blue-700",
ACTIVE: "bg-green-100 text-green-700",
COMPLETED: "bg-purple-100 text-purple-700",
CANCELLED: "bg-red-100 text-red-500",
};
function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500 mb-1">{label}</div>
<div className="text-xl font-bold text-gray-900">{value}</div>
{sub && <div className="text-xs text-gray-400 mt-0.5">{sub}</div>}
</div>
);
}
export function ResourceDetail({ resourceId }: ResourceDetailProps) {
const [editOpen, setEditOpen] = 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;
const loadingResource = _resourceQuery.isLoading;
const error = _resourceQuery.error;
// Fetch allocations for this resource (all non-cancelled)
const now = new Date();
const windowEnd = new Date(now);
windowEnd.setDate(windowEnd.getDate() + 90);
const _allocQuery = trpc.allocation.listView.useQuery(
{ resourceId },
{ enabled: !!resourceId },
) as { data: AllocationReadModel<AllocationLike> | undefined; isLoading: boolean };
const allocations = (_allocQuery.data?.assignments ?? []) as unknown as Array<Pick<
AllocationWithDetails,
"id" | "startDate" | "endDate" | "hoursPerDay" | "dailyCostCents" | "status" | "role" | "roleEntity" | "project"
>>;
const loadingAllocations = _allocQuery.isLoading;
// Fetch upcoming/recent vacations
const vacationStart = new Date(now);
vacationStart.setMonth(vacationStart.getMonth() - 1);
const { data: vacations, isLoading: loadingVacations } = trpc.vacation.list.useQuery(
{
resourceId,
startDate: vacationStart,
limit: 20,
},
{ enabled: !!resourceId },
);
const chargeabilityStatsResult = trpc.resource.getChargeabilityStats.useQuery(
{ resourceId },
{ enabled: canViewCosts, staleTime: 60_000 },
);
const chargeStats = (chargeabilityStatsResult.data as unknown as Array<{
actualChargeability: number;
expectedChargeability: number;
}> | undefined)?.[0];
if (loadingResource) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-64" />
<div className="h-4 bg-gray-100 rounded w-48" />
<div className="grid grid-cols-4 gap-4">
{[0, 1, 2, 3].map((i) => <div key={i} className="h-20 bg-gray-100 rounded-xl" />)}
</div>
</div>
</div>
);
}
if (error || !resource) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-red-700 text-sm">
Resource not found.{" "}
<Link href="/resources" className="underline">Back to resources</Link>
</div>
</div>
);
}
const skills = resource.skills as unknown as SkillEntry[];
const resourceRoles = (resource as unknown as {
resourceRoles?: { isPrimary: boolean; role: { id: string; name: string; color: string | null } }[];
}).resourceRoles ?? [];
const mainSkills = skills.filter((s) => s.isMainSkill);
// Determine if current user owns this resource (self-service)
const resourceWithMeta = resource as unknown as {
userId?: string | null;
portfolioUrl?: string | null;
roleId?: string | null;
aiSummary?: string | null;
aiSummaryUpdatedAt?: Date | null;
skillMatrixUpdatedAt?: Date | null;
areaRole?: { name: string } | null;
valueScore?: number | null;
valueScoreBreakdown?: {
skillDepth: number;
skillBreadth: number;
costEfficiency: number;
chargeability: number;
experience: number;
total: number;
} | null;
valueScoreUpdatedAt?: Date | null;
};
const currentUserEmail = session?.user?.email;
const isOwner = !!(resourceWithMeta.userId && currentUserEmail &&
(resource as unknown as { user?: { email?: string } }).user?.email === currentUserEmail);
const canUpload = isOwner || canEdit;
// Compute stats
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const thisMonthAllocs = (allocations ?? []).filter((a) => {
const start = new Date(a.startDate);
const end = new Date(a.endDate);
return start <= monthEnd && end >= monthStart;
});
let totalHoursThisMonth = 0;
let totalCostCentsThisMonth = 0;
const activeProjectIds = new Set<string>();
for (const a of thisMonthAllocs) {
const start = new Date(Math.max(new Date(a.startDate).getTime(), monthStart.getTime()));
const end = new Date(Math.min(new Date(a.endDate).getTime(), monthEnd.getTime()));
const days = Math.max(0, (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + 1);
totalHoursThisMonth += a.hoursPerDay * days;
totalCostCentsThisMonth += a.dailyCostCents * days;
if (a.project?.id) activeProjectIds.add(a.project.id);
}
const avgDailyCost =
thisMonthAllocs.length > 0
? Math.round(totalCostCentsThisMonth / 100 / (thisMonthAllocs.length || 1))
: 0;
// Filter upcoming/active allocations (not cancelled, ending >= today)
const upcomingAllocations = (allocations ?? []).filter(
(a) => a.status !== "CANCELLED" && new Date(a.endDate) >= now,
);
return (
<div className="p-6 space-y-6">
{/* Back navigation */}
<Link
href="/resources"
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Resources
</Link>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold text-gray-900 truncate">{resource.displayName}</h1>
<span
className={`flex-shrink-0 px-2.5 py-0.5 text-xs font-medium rounded-full ${
resource.isActive ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"
}`}
>
{resource.isActive ? "Active" : "Inactive"}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">
<span className="font-mono">{resource.eid}</span>
{" · "}
<a href={`mailto:${resource.email}`} className="hover:underline">{resource.email}</a>
{resource.chapter && (
<>
{" · "}
<span>{resource.chapter}</span>
</>
)}
</p>
</div>
<div className="flex items-center gap-2">
{canUpload && (
<button
type="button"
onClick={() => setUploadOpen(true)}
className="flex-shrink-0 flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
Update Skill Matrix
</button>
)}
<button
type="button"
onClick={() => setEditOpen(true)}
className="flex-shrink-0 flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
</div>
</div>
{/* Stats cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{canViewCosts && (
<StatCard
label="LCR"
value={`${(resource.lcrCents / 100).toFixed(0)} ${resource.currency}/h`}
/>
)}
{canViewCosts && (
<StatCard
label="UCR"
value={`${(resource.ucrCents / 100).toFixed(0)} ${resource.currency}/h`}
/>
)}
<StatCard
label="Chargeability Target"
value={`${resource.chargeabilityTarget}%`}
/>
{canViewCosts && (
<StatCard
label="Actual (this month)"
value={chargeStats != null ? `${chargeStats.actualChargeability}%` : "—"}
sub="Excl. draft projects"
/>
)}
{canViewCosts && (
<StatCard
label="Expected (this month)"
value={chargeStats != null ? `${chargeStats.expectedChargeability}%` : "—"}
sub="Incl. draft projects"
/>
)}
<StatCard
label="Hours This Month"
value={`${Math.round(totalHoursThisMonth)}h`}
sub={`${activeProjectIds.size} active project${activeProjectIds.size !== 1 ? "s" : ""}`}
/>
{canViewScores && resourceWithMeta.valueScore != null && (
<div className="relative group">
<StatCard
label="Value Score"
value={resourceWithMeta.valueScore}
sub="Price/Quality"
/>
{resourceWithMeta.valueScoreBreakdown && (
<div className="absolute left-0 top-full mt-1 z-10 hidden group-hover:block w-56 bg-white rounded-xl border border-gray-200 shadow-lg p-3 text-xs space-y-1.5">
<p className="font-semibold text-gray-700 mb-2">Score Breakdown</p>
{(
[
["Skill Depth", resourceWithMeta.valueScoreBreakdown.skillDepth],
["Skill Breadth", resourceWithMeta.valueScoreBreakdown.skillBreadth],
["Cost Efficiency", resourceWithMeta.valueScoreBreakdown.costEfficiency],
["Chargeability", resourceWithMeta.valueScoreBreakdown.chargeability],
["Experience", resourceWithMeta.valueScoreBreakdown.experience],
] as [string, number][]
).map(([label, val]) => (
<div key={label}>
<div className="flex justify-between text-gray-600 mb-0.5">
<span>{label}</span>
<span className="font-mono">{val}</span>
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${val >= 70 ? "bg-green-500" : val >= 40 ? "bg-amber-400" : "bg-red-400"}`}
style={{ width: `${val}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Profile meta (area role, portfolio, last import) */}
{(resourceWithMeta.areaRole || resourceWithMeta.portfolioUrl || resourceWithMeta.skillMatrixUpdatedAt) && (
<div className="bg-white rounded-xl border border-gray-200 p-4 flex flex-wrap gap-4 text-sm">
{resourceWithMeta.areaRole && (
<div className="flex items-center gap-2">
<span className="text-gray-500 text-xs">Area:</span>
<span className="font-medium text-gray-800">{resourceWithMeta.areaRole.name}</span>
</div>
)}
{resourceWithMeta.portfolioUrl && (
<div className="flex items-center gap-2">
<span className="text-gray-500 text-xs">Portfolio:</span>
<a
href={resourceWithMeta.portfolioUrl}
target="_blank"
rel="noopener noreferrer"
className="text-brand-600 hover:underline truncate max-w-xs"
>
{resourceWithMeta.portfolioUrl}
</a>
</div>
)}
{resourceWithMeta.skillMatrixUpdatedAt && (
<div className="flex items-center gap-2">
<span className="text-gray-500 text-xs">Skill matrix updated:</span>
<span className="text-gray-600">{formatDate(resourceWithMeta.skillMatrixUpdatedAt)}</span>
</div>
)}
</div>
)}
{/* AI Summary */}
<AiSummaryCard
resourceId={resourceId}
aiSummary={resourceWithMeta.aiSummary ?? null}
aiSummaryUpdatedAt={resourceWithMeta.aiSummaryUpdatedAt ?? null}
onGenerated={async () => { await utils.resource.getById.invalidate({ id: resourceId }); }}
/>
{/* Main Skills Badges */}
{mainSkills.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3">Main Skills</h2>
<div className="flex flex-wrap gap-2">
{mainSkills.map((s) => (
<span
key={s.skill}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-full bg-amber-50 text-amber-800 border border-amber-200"
>
<span className="text-amber-500"></span>
{s.skill}
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${proficiencyColor[s.proficiency] ?? "bg-gray-100 text-gray-500"}`}>
{proficiencyLabel[s.proficiency] ?? `L${s.proficiency}`}
</span>
</span>
))}
</div>
</div>
)}
{/* Skill Radar Chart */}
<SkillRadarChart skills={skills} />
{/* Roles */}
{resourceRoles.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3">Roles</h2>
<div className="flex flex-wrap gap-2">
{resourceRoles.map((rr) => (
<span
key={rr.role.id}
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full"
style={{
backgroundColor: `${rr.role.color ?? "#6366f1"}22`,
color: rr.role.color ?? "#6366f1",
}}
>
{rr.isPrimary && <span className="text-[11px]"></span>}
{rr.role.name}
{rr.isPrimary && <span className="text-[10px] opacity-70">Primary</span>}
</span>
))}
</div>
</div>
)}
{/* Skills */}
{skills.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3">Skills</h2>
<div className="flex flex-wrap gap-2">
{skills.map((s) => (
<span
key={s.skill}
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm rounded-full bg-gray-100 text-gray-700"
>
{s.skill}
{s.proficiency != null && (
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${
proficiencyColor[s.proficiency] ?? "bg-gray-100 text-gray-500"
}`}
>
{proficiencyLabel[s.proficiency] ?? `L${s.proficiency}`}
</span>
)}
{s.yearsExperience != null && (
<span className="text-xs text-gray-400">{s.yearsExperience}y</span>
)}
</span>
))}
</div>
</div>
)}
{/* Active / Upcoming Allocations */}
<div className="bg-white rounded-xl border border-gray-200">
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-800">Active &amp; Upcoming Allocations</h2>
<span className="text-xs text-gray-400">Next 90 days</span>
</div>
{loadingAllocations ? (
<div className="p-6 text-center text-gray-400 text-sm animate-pulse">Loading</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">Project</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">Role</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">Period</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500">h/Day</th>
{canViewCosts && <th className="px-4 py-3 text-right text-xs font-medium text-gray-500">Daily Cost</th>}
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{upcomingAllocations.map((a) => {
const isOver = a.hoursPerDay > 8;
return (
<tr key={a.id} className={`hover:bg-gray-50 ${isOver ? "bg-amber-50" : ""}`}>
<td className="px-4 py-3">
{a.project ? (
<>
<span className="font-mono text-xs text-gray-500 mr-1">{a.project.shortCode}</span>
{a.project.name}
</>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 text-gray-600">{a.role ?? (a.roleEntity?.name ?? "—")}</td>
<td className="px-4 py-3 text-xs text-gray-500">
{formatDate(a.startDate)} {formatDate(a.endDate)}
</td>
<td className={`px-4 py-3 text-right font-medium ${isOver ? "text-amber-600" : "text-gray-900"}`}>
{a.hoursPerDay}h
</td>
{canViewCosts && (
<td className="px-4 py-3 text-right text-gray-700">
{a.dailyCostCents > 0
? `${(a.dailyCostCents / 100).toFixed(0)}/d`
: "—"}
</td>
)}
<td className="px-4 py-3 text-right">
<span
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
allocationStatusColor[a.status] ?? "bg-gray-100 text-gray-600"
}`}
>
{a.status}
</span>
</td>
</tr>
);
})}
{upcomingAllocations.length === 0 && (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-400 text-sm">
No active or upcoming allocations.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
{/* Vacations */}
<div className="bg-white rounded-xl border border-gray-200">
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-800">Vacations</h2>
<span className="text-xs text-gray-400">Last month + upcoming</span>
</div>
{loadingVacations ? (
<div className="p-6 text-center text-gray-400 text-sm animate-pulse">Loading</div>
) : (vacations ?? []).length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">No vacations recorded.</div>
) : (
<div className="divide-y divide-gray-100">
{(vacations ?? []).map((v) => {
const days =
Math.round(
(new Date(v.endDate).getTime() - new Date(v.startDate).getTime()) / (1000 * 60 * 60 * 24),
) + 1;
return (
<div key={v.id} className="px-5 py-3 flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-800">
{v.type.replace(/_/g, " ")}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{formatDate(v.startDate)} {formatDate(v.endDate)}
<span className="ml-1 text-gray-400">({days} day{days !== 1 ? "s" : ""})</span>
</div>
{v.note && (
<div className="text-xs text-gray-400 mt-0.5 italic truncate max-w-sm">{v.note}</div>
)}
</div>
<span
className={`flex-shrink-0 px-2.5 py-0.5 text-xs font-medium rounded-full ${
vacationStatusColor[v.status] ?? "bg-gray-100 text-gray-600"
}`}
>
{v.status}
</span>
</div>
);
})}
</div>
)}
</div>
{/* Edit modal */}
{editOpen && (
<ResourceModal
mode="edit"
resource={resource as unknown as Resource}
onClose={async () => {
setEditOpen(false);
await utils.resource.getById.invalidate({ id: resourceId });
}}
/>
)}
{/* Skill Matrix Upload modal */}
{uploadOpen && (
<SkillMatrixUpload
resourceId={resourceId}
isOwner={isOwner}
onClose={() => setUploadOpen(false)}
onSuccess={async () => {
setUploadOpen(false);
await utils.resource.getById.invalidate({ id: resourceId });
}}
/>
)}
</div>
);
}
@@ -0,0 +1,941 @@
"use client";
import { useRef, useState } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import type { Resource, SkillEntry } from "@planarchy/shared";
import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
interface RoleAssignment {
roleId: string;
isPrimary: boolean;
}
interface SkillRow {
skill: string;
proficiency: 1 | 2 | 3 | 4 | 5;
yearsExperience: string;
category: string;
certified: boolean;
isMainSkill: boolean;
}
interface FormState {
eid: string;
displayName: string;
email: string;
chapter: string;
lcrEuros: string;
ucrEuros: string;
currency: string;
chargeabilityTarget: string;
monday: string;
tuesday: string;
wednesday: string;
thursday: string;
friday: string;
skills: SkillRow[];
roles: RoleAssignment[];
portfolioUrl: string;
roleId: string;
postalCode: string;
federalState: string;
countryId: string;
metroCityId: string;
orgUnitId: string;
managementLevelGroupId: string;
managementLevelId: string;
resourceType: string;
chgResponsibility: boolean;
enterpriseId: string;
clientUnitId: string;
fte: string;
}
function resourceToFormState(resource: Resource): FormState {
const skills = (resource.skills as SkillEntry[]).map((s) => ({
skill: s.skill,
proficiency: s.proficiency,
yearsExperience: s.yearsExperience != null ? String(s.yearsExperience) : "",
category: s.category ?? "",
certified: s.certified ?? false,
isMainSkill: s.isMainSkill ?? false,
}));
const roles: RoleAssignment[] = (resource.roles ?? []).map((r) => ({
roleId: r.roleId,
isPrimary: r.isPrimary,
}));
const resourceWithMeta = resource as unknown as {
portfolioUrl?: string | null;
roleId?: string | null;
};
return {
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
chapter: resource.chapter ?? "",
lcrEuros: String(resource.lcrCents / 100),
ucrEuros: String(resource.ucrCents / 100),
currency: resource.currency,
chargeabilityTarget: String(resource.chargeabilityTarget),
monday: String(resource.availability.monday),
tuesday: String(resource.availability.tuesday),
wednesday: String(resource.availability.wednesday),
thursday: String(resource.availability.thursday),
friday: String(resource.availability.friday),
skills,
roles,
portfolioUrl: resourceWithMeta.portfolioUrl ?? "",
roleId: resourceWithMeta.roleId ?? "",
postalCode: (resource as unknown as { postalCode?: string | null }).postalCode ?? "",
federalState: (resource as unknown as { federalState?: string | null }).federalState ?? "",
countryId: (resource as unknown as { countryId?: string | null }).countryId ?? "",
metroCityId: (resource as unknown as { metroCityId?: string | null }).metroCityId ?? "",
orgUnitId: (resource as unknown as { orgUnitId?: string | null }).orgUnitId ?? "",
managementLevelGroupId: (resource as unknown as { managementLevelGroupId?: string | null }).managementLevelGroupId ?? "",
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,
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),
};
}
function defaultFormState(): FormState {
return {
eid: "",
displayName: "",
email: "",
chapter: "",
lcrEuros: "",
ucrEuros: "",
currency: "EUR",
chargeabilityTarget: "80",
monday: "8",
tuesday: "8",
wednesday: "8",
thursday: "8",
friday: "8",
skills: [],
roles: [],
portfolioUrl: "",
roleId: "",
postalCode: "",
federalState: "",
countryId: "",
metroCityId: "",
orgUnitId: "",
managementLevelGroupId: "",
managementLevelId: "",
resourceType: "EMPLOYEE",
chgResponsibility: true,
enterpriseId: "",
clientUnitId: "",
fte: "1",
};
}
function defaultSkillRow(): SkillRow {
return { skill: "", proficiency: 3, yearsExperience: "", category: "", certified: false, isMainSkill: false };
}
interface ResourceModalProps {
mode: "create" | "edit";
resource?: Resource;
onClose: () => void;
}
const INPUT_CLASS =
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100";
const LABEL_CLASS = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const SECTION_HEADER_CLASS = "text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-4";
const PRIMARY_BTN =
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
function Spinner() {
return (
<svg
className="animate-spin h-4 w-4 text-white inline-block mr-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}
export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
const [form, setForm] = useState<FormState>(() =>
resource ? resourceToFormState(resource) : defaultFormState(),
);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils();
const { data: availableRoles } = trpc.role.list.useQuery(
{ isActive: true },
{ staleTime: 60_000 },
);
const { data: countries } = trpc.country.list.useQuery(undefined, { staleTime: 60_000 });
const { data: orgUnits } = trpc.orgUnit.list.useQuery(undefined, { staleTime: 60_000 });
const { data: mgmtGroups } = trpc.managementLevel.listGroups.useQuery(undefined, { staleTime: 60_000 });
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 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 mgmtLevels = selectedGroup?.levels ?? [];
const createMutation = trpc.resource.create.useMutation({
onSuccess: async () => {
await utils.resource.list.invalidate();
onClose();
},
onError: (err) => {
setErrorMsg(err.message ?? "An error occurred while saving.");
},
});
const updateMutation = trpc.resource.update.useMutation({
onSuccess: async () => {
await utils.resource.list.invalidate();
onClose();
},
onError: (err) => {
setErrorMsg(err.message ?? "An error occurred while saving.");
},
});
const isMutating = createMutation.isPending || updateMutation.isPending;
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
setErrorMsg(null);
}
function setSkillField(index: number, key: keyof SkillRow, value: string | number | boolean) {
setForm((prev) => {
const skills = prev.skills.map((s, i) => (i === index ? { ...s, [key]: value } : s));
return { ...prev, skills };
});
}
function addSkill() {
setForm((prev) => ({ ...prev, skills: [...prev.skills, defaultSkillRow()] }));
}
function removeSkill(index: number) {
setForm((prev) => ({ ...prev, skills: prev.skills.filter((_, i) => i !== index) }));
}
function buildPayload() {
const lcrCents = Math.round(parseFloat(form.lcrEuros || "0") * 100);
const ucrCents = Math.round(parseFloat(form.ucrEuros || "0") * 100);
const chargeabilityTarget = parseFloat(form.chargeabilityTarget || "80");
const mainSkillCount = form.skills.filter((s) => s.isMainSkill).length;
const skills = form.skills
.filter((s) => s.skill.trim() !== "")
.map((s) => ({
skill: s.skill.trim(),
proficiency: s.proficiency,
...(s.yearsExperience !== "" ? { yearsExperience: parseFloat(s.yearsExperience) } : {}),
...(s.category.trim() !== "" ? { category: s.category.trim() } : {}),
...(s.certified ? { certified: s.certified } : {}),
...(s.isMainSkill ? { isMainSkill: true } : {}),
}));
void mainSkillCount; // used for UI validation only
return {
eid: form.eid.trim(),
displayName: form.displayName.trim(),
email: form.email.trim(),
...(form.chapter.trim() !== "" ? { chapter: form.chapter.trim() } : {}),
lcrCents,
ucrCents,
currency: form.currency,
chargeabilityTarget,
availability: {
monday: parseFloat(form.monday || "8"),
tuesday: parseFloat(form.tuesday || "8"),
wednesday: parseFloat(form.wednesday || "8"),
thursday: parseFloat(form.thursday || "8"),
friday: parseFloat(form.friday || "8"),
},
skills,
roles: form.roles,
...(form.portfolioUrl.trim() !== "" ? { portfolioUrl: form.portfolioUrl.trim() } : {}),
...(form.roleId.trim() !== "" ? { roleId: form.roleId.trim() } : {}),
...(form.postalCode.trim() !== "" ? { postalCode: form.postalCode.trim() } : {}),
...(form.federalState.trim() !== "" ? { federalState: form.federalState.trim() } : {}),
...(form.countryId ? { countryId: form.countryId } : {}),
...(form.metroCityId ? { metroCityId: form.metroCityId } : {}),
...(form.orgUnitId ? { orgUnitId: form.orgUnitId } : {}),
...(form.managementLevelGroupId ? { managementLevelGroupId: form.managementLevelGroupId } : {}),
...(form.managementLevelId ? { managementLevelId: form.managementLevelId } : {}),
resourceType: form.resourceType as ResourceType,
chgResponsibility: form.chgResponsibility,
...(form.enterpriseId.trim() !== "" ? { enterpriseId: form.enterpriseId.trim() } : {}),
...(form.clientUnitId ? { clientUnitId: form.clientUnitId } : {}),
fte: parseFloat(form.fte) || 1,
};
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setErrorMsg(null);
const payload = buildPayload();
if (mode === "create") {
createMutation.mutate(payload);
} else if (resource) {
updateMutation.mutate({ id: resource.id, data: payload });
}
}
const proficiencyLabels: Record<number, string> = {
1: "1 Beginner",
2: "2 Elementary",
3: "3 Intermediate",
4: "4 Advanced",
5: "5 Expert",
};
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{mode === "create" ? "New Resource" : "Edit Resource"}
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close modal"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} noValidate>
<div className="px-6 pb-4">
{/* Section 1: Basic Info */}
<p className={SECTION_HEADER_CLASS}>Basic Info</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-eid">
Employee ID <span className="text-red-500">*</span>
</label>
<input
id="rm-eid"
type="text"
className={INPUT_CLASS}
placeholder="EMP-042"
value={form.eid}
onChange={(e) => setField("eid", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-displayName">
Display Name <span className="text-red-500">*</span>
</label>
<input
id="rm-displayName"
type="text"
className={INPUT_CLASS}
placeholder="Jane Smith"
value={form.displayName}
onChange={(e) => setField("displayName", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-email">
Email <span className="text-red-500">*</span>
</label>
<input
id="rm-email"
type="email"
className={INPUT_CLASS}
placeholder="jane@example.com"
value={form.email}
onChange={(e) => setField("email", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-chapter">
Chapter <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<input
id="rm-chapter"
type="text"
className={INPUT_CLASS}
placeholder="Engineering"
value={form.chapter}
onChange={(e) => setField("chapter", e.target.value)}
/>
</div>
</div>
{/* Portfolio & Role */}
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-portfolioUrl">
Portfolio URL <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<input
id="rm-portfolioUrl"
type="url"
className={INPUT_CLASS}
placeholder="https://artstation.com/…"
value={form.portfolioUrl}
onChange={(e) => setField("portfolioUrl", e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-roleId">
Area of Expertise <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<select
id="rm-roleId"
className={INPUT_CLASS}
value={form.roleId}
onChange={(e) => setField("roleId", e.target.value)}
>
<option value=""> Not specified </option>
{(availableRoles ?? []).map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</div>
</div>
{/* Postal Code & Federal State */}
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-postalCode">
Postal Code (PLZ) <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<input
id="rm-postalCode"
type="text"
className={INPUT_CLASS}
placeholder="80331"
maxLength={5}
value={form.postalCode}
onChange={(e) => {
const plz = e.target.value;
setField("postalCode", plz);
if (/^\d{5}$/.test(plz)) {
const inferred = inferStateFromPostalCode(plz);
if (inferred && !form.federalState) {
setField("federalState", inferred);
}
}
}}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-federalState">
Federal State <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<select
id="rm-federalState"
className={INPUT_CLASS}
value={form.federalState}
onChange={(e) => setField("federalState", e.target.value)}
>
<option value=""> Not specified </option>
{Object.entries(GERMAN_FEDERAL_STATES).map(([abbr, name]) => (
<option key={abbr} value={abbr}>{name} ({abbr})</option>
))}
</select>
</div>
</div>
{/* Section: Organization & Classification */}
<p className={SECTION_HEADER_CLASS}>Organization &amp; Classification</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-enterpriseId">
Enterprise ID <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<input
id="rm-enterpriseId"
type="text"
className={INPUT_CLASS}
placeholder="a.kasperovich"
value={form.enterpriseId}
onChange={(e) => setField("enterpriseId", e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-fte">
FTE
</label>
<input
id="rm-fte"
type="number"
min="0.01"
max="1"
step="0.01"
className={INPUT_CLASS}
placeholder="1.0"
value={form.fte}
onChange={(e) => setField("fte", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-countryId">Country</label>
<select
id="rm-countryId"
className={INPUT_CLASS}
value={form.countryId}
onChange={(e) => {
setField("countryId", e.target.value);
setField("metroCityId", ""); // reset city when country changes
}}
>
<option value=""> Not specified </option>
{(countries ?? []).map((c) => (
<option key={c.id} value={c.id}>{(c as unknown as { name: string }).name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-metroCityId">Metro City</label>
<select
id="rm-metroCityId"
className={INPUT_CLASS}
value={form.metroCityId}
onChange={(e) => setField("metroCityId", e.target.value)}
disabled={!form.countryId}
>
<option value=""> Not specified </option>
{metroCities.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-orgUnitId">Org Unit (L7 Team)</label>
<select
id="rm-orgUnitId"
className={INPUT_CLASS}
value={form.orgUnitId}
onChange={(e) => setField("orgUnitId", e.target.value)}
>
<option value=""> Not specified </option>
{(orgUnits ?? [])
.filter((u) => (u as unknown as { level: number }).level === 7 && (u as unknown as { isActive: boolean }).isActive)
.map((u) => (
<option key={u.id} value={u.id}>{(u as unknown as { name: string }).name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-clientUnitId">Client Unit</label>
<select
id="rm-clientUnitId"
className={INPUT_CLASS}
value={form.clientUnitId}
onChange={(e) => setField("clientUnitId", e.target.value)}
>
<option value=""> Not specified </option>
{(clients ?? []).map((c) => (
<option key={c.id} value={c.id}>{(c as unknown as { name: string }).name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-mgmtGroupId">Management Level Group</label>
<select
id="rm-mgmtGroupId"
className={INPUT_CLASS}
value={form.managementLevelGroupId}
onChange={(e) => {
setField("managementLevelGroupId", e.target.value);
setField("managementLevelId", ""); // reset level when group changes
}}
>
<option value=""> Not specified </option>
{(mgmtGroups ?? []).map((g) => (
<option key={g.id} value={g.id}>{(g as unknown as { name: string }).name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-mgmtLevelId">Management Level</label>
<select
id="rm-mgmtLevelId"
className={INPUT_CLASS}
value={form.managementLevelId}
onChange={(e) => setField("managementLevelId", e.target.value)}
disabled={!form.managementLevelGroupId}
>
<option value=""> Not specified </option>
{mgmtLevels.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-resourceType">Resource Type</label>
<select
id="rm-resourceType"
className={INPUT_CLASS}
value={form.resourceType}
onChange={(e) => setField("resourceType", e.target.value)}
>
{Object.values(ResourceType).map((t) => (
<option key={t} value={t}>{t.charAt(0) + t.slice(1).toLowerCase()}</option>
))}
</select>
</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.chgResponsibility}
onChange={(e) => setField("chgResponsibility", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Chg Responsibility
</label>
</div>
</div>
{/* Section 2: Cost & Chargeability */}
<p className={SECTION_HEADER_CLASS}>Cost &amp; Chargeability</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-lcr">
LCR &euro;/h <span className="text-red-500">*</span>
</label>
<input
id="rm-lcr"
type="number"
min="0"
step="0.01"
className={INPUT_CLASS}
placeholder="80"
value={form.lcrEuros}
onChange={(e) => setField("lcrEuros", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-ucr">
UCR &euro;/h <span className="text-red-500">*</span>
</label>
<input
id="rm-ucr"
type="number"
min="0"
step="0.01"
className={INPUT_CLASS}
placeholder="120"
value={form.ucrEuros}
onChange={(e) => setField("ucrEuros", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-currency">
Currency
</label>
<select
id="rm-currency"
className={INPUT_CLASS}
value={form.currency}
onChange={(e) => setField("currency", e.target.value)}
>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-chargeability">
Chargeability Target %
</label>
<input
id="rm-chargeability"
type="number"
min="0"
max="100"
className={INPUT_CLASS}
placeholder="80"
value={form.chargeabilityTarget}
onChange={(e) => setField("chargeabilityTarget", e.target.value)}
/>
</div>
</div>
{/* Section 3: Weekly Availability */}
<p className={SECTION_HEADER_CLASS}>Weekly Availability (hours/day)</p>
<div className="grid grid-cols-5 gap-3">
{(
[
["monday", "Mon"],
["tuesday", "Tue"],
["wednesday", "Wed"],
["thursday", "Thu"],
["friday", "Fri"],
] as const
).map(([day, label]) => (
<div key={day}>
<label className={LABEL_CLASS} htmlFor={`rm-${day}`}>
{label}
</label>
<input
id={`rm-${day}`}
type="number"
min="0"
max="24"
step="0.5"
className={INPUT_CLASS}
value={form[day]}
onChange={(e) => setField(day, e.target.value)}
/>
</div>
))}
</div>
{/* Section 4: Skills */}
<p className={SECTION_HEADER_CLASS}>Skills</p>
<div className="space-y-3">
{form.skills.map((skillRow, idx) => {
const mainSkillCount = form.skills.filter((s) => s.isMainSkill).length;
const canToggleMain = skillRow.isMainSkill || mainSkillCount < 2;
return (
<div
key={idx}
className={`grid gap-2 items-end border rounded-lg p-3 ${skillRow.isMainSkill ? "border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20" : "border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"}`}
>
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-2 items-end">
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-name-${idx}`}>
Skill
</label>
<input
id={`rm-skill-name-${idx}`}
type="text"
className={INPUT_CLASS}
placeholder="e.g. 3ds Max"
value={skillRow.skill}
onChange={(e) => setSkillField(idx, "skill", e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-prof-${idx}`}>
Proficiency
</label>
<select
id={`rm-skill-prof-${idx}`}
className={INPUT_CLASS}
value={skillRow.proficiency}
onChange={(e) =>
setSkillField(idx, "proficiency", parseInt(e.target.value, 10) as 1 | 2 | 3 | 4 | 5)
}
>
{[1, 2, 3, 4, 5].map((p) => (
<option key={p} value={p}>
{proficiencyLabels[p]}
</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-years-${idx}`}>
Years
</label>
<input
id={`rm-skill-years-${idx}`}
type="number"
min="0"
max="50"
step="1"
className={INPUT_CLASS}
placeholder="—"
value={skillRow.yearsExperience}
onChange={(e) => setSkillField(idx, "yearsExperience", e.target.value)}
/>
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[10px] text-gray-500 dark:text-gray-400 leading-none"> Main</span>
<input
type="checkbox"
checked={skillRow.isMainSkill}
disabled={!canToggleMain}
title={!canToggleMain ? "Max 2 main skills" : "Mark as main skill"}
onChange={(e) => setSkillField(idx, "isMainSkill", e.target.checked)}
className="rounded border-gray-300 disabled:opacity-40"
/>
</div>
<div className="flex items-end pb-0.5">
<button
type="button"
onClick={() => removeSkill(idx)}
className="px-2 py-2 text-red-400 hover:text-red-600 transition-colors"
aria-label={`Remove skill ${idx + 1}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
);
})}
<button
type="button"
onClick={addSkill}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add skill
</button>
</div>
{/* Section 5: Roles */}
<p className={SECTION_HEADER_CLASS}>Roles</p>
<div className="space-y-2">
{(availableRoles ?? []).map((role) => {
const assignment = form.roles.find((r) => r.roleId === role.id);
const isChecked = Boolean(assignment);
return (
<div key={role.id} className="flex items-center gap-3 py-1">
<input
type="checkbox"
id={`role-${role.id}`}
checked={isChecked}
onChange={(e) => {
if (e.target.checked) {
setField("roles", [...form.roles, { roleId: role.id, isPrimary: false }]);
} else {
setField("roles", form.roles.filter((r) => r.roleId !== role.id));
}
}}
className="rounded border-gray-300"
/>
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: role.color ?? "#6366f1" }}
/>
<label htmlFor={`role-${role.id}`} className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer flex-1">
{role.name}
</label>
{isChecked && (
<label className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 cursor-pointer">
<input
type="radio"
name="primary-role"
checked={assignment?.isPrimary ?? false}
onChange={() => {
setField("roles", form.roles.map((r) =>
r.roleId === role.id
? { ...r, isPrimary: true }
: { ...r, isPrimary: false },
));
}}
className="border-gray-300"
/>
Primary
</label>
)}
</div>
);
})}
{(availableRoles ?? []).length === 0 && (
<p className="text-sm text-gray-400 italic">No roles defined yet. Create roles on the Roles page.</p>
)}
</div>
{/* Error message */}
{errorMsg && (
<div className="mt-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{errorMsg}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-xl">
<button
type="button"
onClick={onClose}
disabled={isMutating}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button type="submit" disabled={isMutating} className={PRIMARY_BTN}>
{isMutating && <Spinner />}
{isMutating ? "Saving…" : "Save"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,221 @@
"use client";
import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
import type { SkillEntry } from "@planarchy/shared";
interface Props {
resourceId: string;
isOwner: boolean; // true = self-service, false = manager import
onClose: () => void;
onSuccess: () => void;
}
type PreviewState = {
skills: SkillEntry[];
employeeInfo: {
roleId?: string;
portfolioUrl?: string;
};
matchedRoleName?: string;
};
export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: Props) {
const [preview, setPreview] = useState<PreviewState | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const { data: roles } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
const selfMutation = trpc.resource.importSkillMatrix.useMutation({
onSuccess: () => { setSubmitting(false); onSuccess(); },
onError: (err) => { setSubmitting(false); setParseError(err.message); },
});
const managerMutation = trpc.resource.importSkillMatrixForResource.useMutation({
onSuccess: () => { setSubmitting(false); onSuccess(); },
onError: (err) => { setSubmitting(false); setParseError(err.message); },
});
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setParseError(null);
setPreview(null);
try {
const buffer = await file.arrayBuffer();
const parsed = parseSkillMatrixWorkbook(buffer);
// Fuzzy match areaOfExpertise → roleId
let roleId: string | undefined;
let matchedRoleName: string | undefined;
if (parsed.employeeInfo.areaOfExpertise && roles) {
const roleNames = roles.map((r) => r.name);
const matched = matchRoleName(parsed.employeeInfo.areaOfExpertise, roleNames);
if (matched) {
const role = roles.find((r) => r.name === matched);
roleId = role?.id;
matchedRoleName = matched;
}
}
setPreview({
skills: parsed.skills,
employeeInfo: {
...(roleId !== undefined ? { roleId } : {}),
...(parsed.employeeInfo.portfolioUrl !== undefined ? { portfolioUrl: parsed.employeeInfo.portfolioUrl } : {}),
},
...(matchedRoleName !== undefined ? { matchedRoleName } : {}),
});
} catch (err) {
setParseError(String(err instanceof Error ? err.message : err));
}
}
function handleConfirm() {
if (!preview) return;
setSubmitting(true);
const payload = {
skills: preview.skills,
employeeInfo: {
roleId: preview.employeeInfo.roleId,
portfolioUrl: preview.employeeInfo.portfolioUrl,
},
};
if (isOwner) {
selfMutation.mutate(payload);
} else {
managerMutation.mutate({ resourceId, ...payload });
}
}
const mainSkills = preview?.skills.filter((s) => s.isMainSkill) ?? [];
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-900">Update Skill Matrix</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-6 py-5 space-y-4">
{/* File picker */}
{!preview && (
<div
className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors"
onClick={() => fileRef.current?.click()}
>
<svg className="w-10 h-10 text-gray-300 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-sm font-medium text-gray-700">Click to select skill matrix file</p>
<p className="text-xs text-gray-400 mt-1">.xlsx accepted</p>
<input
ref={fileRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={handleFile}
/>
</div>
)}
{parseError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{parseError}
</div>
)}
{/* Preview */}
{preview && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="bg-brand-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-brand-700">{preview.skills.length}</div>
<div className="text-xs text-brand-600 mt-0.5">Skills found</div>
</div>
<div className="bg-green-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-green-700">{mainSkills.length}</div>
<div className="text-xs text-green-600 mt-0.5">Main skills</div>
</div>
</div>
{mainSkills.length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 mb-1.5">Main skills:</p>
<div className="flex flex-wrap gap-1.5">
{mainSkills.map((s) => (
<span key={s.skill} className="px-2.5 py-0.5 text-xs font-medium rounded-full bg-amber-50 text-amber-700 border border-amber-200">
{s.skill}
</span>
))}
</div>
</div>
)}
{preview.matchedRoleName && (
<p className="text-xs text-gray-600">
<span className="font-medium">Area of expertise</span> matched to Planarchy role:{" "}
<span className="font-semibold text-brand-700">{preview.matchedRoleName}</span>
</p>
)}
{preview.employeeInfo.portfolioUrl && (
<p className="text-xs text-gray-600 truncate">
<span className="font-medium">Portfolio URL:</span>{" "}
<a href={preview.employeeInfo.portfolioUrl} target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline">
{preview.employeeInfo.portfolioUrl}
</a>
</p>
)}
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
This will <strong>replace all existing skills</strong> for this resource.
</p>
<button
type="button"
onClick={() => { setPreview(null); setParseError(null); if (fileRef.current) fileRef.current.value = ""; }}
className="text-xs text-gray-400 hover:text-gray-600 underline"
>
Choose a different file
</button>
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-xl">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={!preview || submitting}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{submitting ? "Importing…" : "Confirm Import"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useRef, useEffect, useState } from "react";
import {
RadarChart,
PolarGrid,
PolarAngleAxis,
Radar,
Tooltip,
} from "recharts";
import type { SkillEntry } from "@planarchy/shared";
interface Props {
skills: SkillEntry[];
}
export function SkillRadarChart({ skills }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
if (entry) setWidth(entry.contentRect.width);
});
ro.observe(el);
setWidth(el.getBoundingClientRect().width);
return () => ro.disconnect();
}, []);
if (skills.length === 0) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-4">Skill Profile</h2>
<div className="flex items-center justify-center h-48 text-sm text-gray-400 dark:text-gray-500">
No skills recorded yet
</div>
</div>
);
}
const categoryMap = new Map<string, number[]>();
for (const s of skills) {
const cat = s.category ?? "Other";
if (!categoryMap.has(cat)) categoryMap.set(cat, []);
categoryMap.get(cat)!.push(s.proficiency);
}
const data = Array.from(categoryMap.entries())
.map(([category, profs]) => ({
category,
score: Math.round((profs.reduce((a, b) => a + b, 0) / profs.length) * 20),
}))
.sort((a, b) => b.score - a.score)
.slice(0, 8);
if (data.length < 3) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-4">Skill Profile</h2>
<div ref={containerRef} style={{ height: 260 }}>
{width > 0 && (
<RadarChart
width={width}
height={260}
data={data}
margin={{ top: 10, right: 30, bottom: 10, left: 30 }}
>
<PolarGrid stroke="#e5e7eb" />
<PolarAngleAxis
dataKey="category"
tick={{ fontSize: 11, fill: "#6b7280" }}
/>
<Tooltip
formatter={(value: number | undefined) => [`${value ?? 0}%`, "Avg proficiency"] as [string, string]}
contentStyle={{ fontSize: 12, borderRadius: 8 }}
/>
<Radar
name="Skills"
dataKey="score"
stroke="#6366f1"
fill="#6366f1"
fillOpacity={0.2}
strokeWidth={2}
isAnimationActive={false}
/>
</RadarChart>
)}
</div>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { useRef, useState } from "react";
import type { RoleWithResourceCount } from "@planarchy/shared";
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",
];
interface RoleModalProps {
role?: RoleWithResourceCount | null;
onClose: () => void;
onSuccess: () => void;
}
export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
const isEditing = Boolean(role);
const [name, setName] = useState(role?.name ?? "");
const [description, setDescription] = useState(role?.description ?? "");
const [color, setColor] = useState(role?.color ?? PRESET_COLORS[0]!);
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils();
const createMutation = trpc.role.create.useMutation({
onSuccess: async () => {
await utils.role.list.invalidate();
onSuccess();
},
onError: (err) => setServerError(err.message),
});
const updateMutation = trpc.role.update.useMutation({
onSuccess: async () => {
await utils.role.list.invalidate();
onSuccess();
},
onError: (err) => setServerError(err.message),
});
const isPending = createMutation.isPending || updateMutation.isPending;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setServerError(null);
if (!name.trim()) {
setServerError("Name is required.");
return;
}
const payload = {
name: name.trim(),
description: description.trim() || undefined,
color: color || undefined,
};
if (isEditing && role) {
updateMutation.mutate({ id: role.id, data: payload });
} else {
createMutation.mutate(payload);
}
}
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";
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
ref={panelRef}
className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
{isEditing ? "Edit Role" : "New Role"}
</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
<div>
<label className={labelClass}>Name <span className="text-red-500">*</span></label>
<input
type="text"
value={name}
onChange={(e) => { setName(e.target.value); setServerError(null); }}
placeholder="e.g. 3D Artist"
className={inputClass}
maxLength={100}
required
/>
</div>
<div>
<label className={labelClass}>Description</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
className={inputClass}
maxLength={500}
/>
</div>
<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="flex flex-wrap gap-2 flex-1">
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110"
style={{
backgroundColor: c,
borderColor: color === c ? "#1f2937" : "transparent",
}}
/>
))}
</div>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 rounded cursor-pointer border border-gray-300"
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">
{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">
Cancel
</button>
<button type="submit" disabled={isPending} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50">
{isPending ? "Saving…" : "Save"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,285 @@
"use client";
import { useState } from "react";
import type { RoleWithResourceCount } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { RoleModal } from "./RoleModal.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
export function RolesClient() {
const [search, setSearch] = useState("");
const [showInactive, setShowInactive] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editRole, setEditRole] = useState<RoleWithResourceCount | null>(null);
const [confirmDelete, setConfirmDelete] = useState<RoleWithResourceCount | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: roles, isLoading } = trpc.role.list.useQuery(
{ isActive: showInactive ? undefined : true, search: search || undefined },
{ placeholderData: (prev) => prev, staleTime: 60_000 },
);
const deactivateMutation = trpc.role.deactivate.useMutation({
onSuccess: async () => {
await utils.role.list.invalidate();
},
onError: (err) => setActionError(err.message),
});
const activateMutation = trpc.role.update.useMutation({
onSuccess: async () => {
await utils.role.list.invalidate();
},
onError: (err) => setActionError(err.message),
});
const deleteMutation = trpc.role.delete.useMutation({
onSuccess: async () => {
await utils.role.list.invalidate();
setConfirmDelete(null);
},
onError: (err) => {
setActionError(err.message);
setConfirmDelete(null);
},
});
function openCreate() {
setEditRole(null);
setModalOpen(true);
}
function openEdit(role: RoleWithResourceCount) {
setEditRole(role);
setModalOpen(true);
}
const roleList = (roles ?? []) as unknown as RoleWithResourceCount[];
const rolesViewPrefs = useViewPrefs("roles");
const { sorted, sortField, sortDir, toggle } = useTableSort(roleList, {
initialField: rolesViewPrefs.savedSort?.field ?? null,
initialDir: rolesViewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
rolesViewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
function clearAll() {
setSearch("");
setShowInactive(false);
}
const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(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>
<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>
</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"
>
+ New Role
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 mb-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"
/>
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="rounded border-gray-300"
/>
Show inactive
</label>
</div>
{chips.length > 0 && (
<div className="mb-3">
<FilterChips chips={chips} onClearAll={clearAll} />
</div>
)}
{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">
{actionError}
<button type="button" onClick={() => setActionError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
</div>
)}
{/* Table */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<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>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-400">Loading</td>
</tr>
)}
{!isLoading && sorted.length === 0 && (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-400">
No roles found. Create one to get started.
</td>
</tr>
)}
{sorted.map((role) => (
<tr
key={role.id}
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div
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>
</div>
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 max-w-xs truncate">
{role.description ?? <span className="italic text-gray-300"></span>}
</td>
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center justify-center w-8 h-6 rounded bg-gray-100 dark:bg-gray-800 text-xs font-medium text-gray-600 dark:text-gray-400">
{role._count.resourceRoles}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center justify-center w-8 h-6 rounded bg-gray-100 dark:bg-gray-800 text-xs font-medium text-gray-600 dark:text-gray-400">
{role._count.allocations}
</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"
}`}>
{role.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => openEdit(role)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
{role.isActive && (
<button
type="button"
onClick={() => {
setActionError(null);
deactivateMutation.mutate({ id: role.id });
}}
disabled={deactivateMutation.isPending}
className="text-xs text-gray-500 hover:text-gray-700"
>
Deactivate
</button>
)}
{!role.isActive && (
<button
type="button"
onClick={() => {
setActionError(null);
activateMutation.mutate({ id: role.id, data: { isActive: true } });
}}
disabled={activateMutation.isPending}
className="text-xs text-green-600 hover:text-green-800 font-medium"
>
Enable
</button>
)}
<button
type="button"
onClick={() => { setConfirmDelete(role); setActionError(null); }}
className="text-xs text-red-500 hover:text-red-700"
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Modals */}
{modalOpen && (
<RoleModal
role={editRole}
onClose={() => setModalOpen(false)}
onSuccess={() => setModalOpen(false)}
/>
)}
{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">
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.
</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">
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"
>
{deleteMutation.isPending ? "Deleting…" : "Delete"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,178 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
export function StaffingPanel() {
const [requiredSkills, setRequiredSkills] = useState<string[]>(["TypeScript", "React"]);
const [startDate, setStartDate] = useState(new Date().toISOString().split("T")[0] ?? "");
const [endDate, setEndDate] = useState(
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split("T")[0] ?? "",
);
const [hoursPerDay, setHoursPerDay] = useState(8);
const [submitted, setSubmitted] = useState(false);
const { data: suggestions, isLoading } = trpc.staffing.getSuggestions.useQuery(
{
requiredSkills: requiredSkills,
startDate: new Date(startDate),
endDate: new Date(endDate),
hoursPerDay,
},
{ enabled: submitted },
);
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>
</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>
)}
{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>
)}
{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>
<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>
</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>
)}
</div>
</div>
);
}
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="h-full bg-brand-500 rounded-full"
style={{ width: `${value}%` }}
/>
</div>
<div className="text-xs text-gray-600 mt-0.5">{value}</div>
</div>
);
}
@@ -0,0 +1,226 @@
"use client";
import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import type { AllocationLike, AllocationReadModel, Assignment } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js";
interface AllocationPopoverProps {
allocationId: string;
projectId: string;
onClose: () => void;
onOpenPanel: (projectId: string) => void;
/** Pixel position relative to the viewport */
anchorX: number;
anchorY: number;
}
type AllocationPopoverAssignment = Assignment<AllocationLike>;
export function AllocationPopover({
allocationId,
projectId,
onClose,
onOpenPanel,
anchorX,
anchorY,
}: AllocationPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils();
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
{ projectId },
{ staleTime: 10_000 },
) as { data: AllocationReadModel<AllocationLike> | undefined; isLoading: boolean };
const allocation = allocationView?.assignments.find((entry) => entry.id === allocationId) as AllocationPopoverAssignment | undefined;
const [hoursPerDay, setHoursPerDay] = useState<number | null>(null);
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
const [includeSaturday, setIncludeSaturday] = useState(false);
const [role, setRole] = useState("");
useEffect(() => {
if (allocation) {
setHoursPerDay(allocation.hoursPerDay);
setStartDate(toDateInput(new Date(allocation.startDate)));
setEndDate(toDateInput(new Date(allocation.endDate)));
const meta = allocation.metadata as { includeSaturday?: boolean } | null;
setIncludeSaturday(meta?.includeSaturday ?? false);
setRole(allocation.role ?? "");
}
}, [allocation]);
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
void utils.allocation.listView.invalidate();
onClose();
},
});
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
function toDateInput(d: Date): string {
return d.toISOString().split("T")[0] ?? "";
}
function handleSave() {
if (!allocation || hoursPerDay === null) return;
updateMutation.mutate({
allocationId: getPlanningEntryMutationId(allocation),
hoursPerDay,
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
includeSaturday,
role: role || undefined,
});
}
// Position popover so it stays on screen
const popoverStyle: React.CSSProperties = {
position: "fixed",
left: Math.min(anchorX, window.innerWidth - 320),
top: Math.min(anchorY + 8, window.innerHeight - 360),
zIndex: 50,
width: 300,
};
if (isLoading || !allocation) {
return (
<div ref={ref} style={popoverStyle} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
Loading...
</div>
);
}
const dailyCostEUR = ((hoursPerDay ?? allocation.hoursPerDay) * (allocation.resource?.lcrCents ?? 0) / 100).toFixed(2);
return (
<div
ref={ref}
style={popoverStyle}
className="bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
<div>
<span className="text-sm font-semibold text-gray-800">{role}</span>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
</div>
<div className="p-4 space-y-3">
{/* Resource */}
<div className="text-xs text-gray-500">
Resource: <span className="font-medium text-gray-700">{allocation.resource?.displayName}</span>
{" "}· <span className="text-gray-400">{allocation.resource?.eid}</span>
</div>
{/* Role */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Role</label>
<input
type="text"
value={role}
onChange={(e) => setRole(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
{/* Hours per day */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Hours / day</label>
<input
type="number"
min={0.5}
max={24}
step={0.5}
value={hoursPerDay ?? ""}
onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
{/* Date range */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Start</label>
<DateInput
value={startDate}
onChange={setStartDate}
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">End</label>
<DateInput
value={endDate}
onChange={setEndDate}
min={startDate}
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
{/* Include Saturday */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeSaturday}
onChange={(e) => setIncludeSaturday(e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-400"
/>
<span className="text-xs text-gray-700">Include Saturdays</span>
</label>
{/* Error */}
{updateMutation.isError && (
<p className="text-xs text-red-600">{updateMutation.error.message}</p>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
<button
onClick={handleSave}
disabled={updateMutation.isPending}
className={clsx(
"flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors",
"bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50",
)}
>
{updateMutation.isPending ? "Saving…" : "Save"}
</button>
<button
onClick={onClose}
className="flex-1 py-1.5 rounded-lg text-sm font-medium border border-gray-200 text-gray-600 hover:bg-gray-50"
>
Cancel
</button>
</div>
{/* Link to full panel */}
<button
onClick={() => { onClose(); onOpenPanel(projectId); }}
className="w-full text-xs text-brand-600 hover:text-brand-800 text-center pt-1"
>
Open Project Panel
</button>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
"use client";
import { clsx } from "clsx";
import { memo } from "react";
interface ConflictOverlayProps {
/** Pixel left offset within the canvas */
left: number;
/** Pixel width */
width: number;
/** Row height */
height: number;
/** Conflict type */
type: "availability" | "overlap" | "budget";
message?: string;
}
export const ConflictOverlay = memo(function ConflictOverlay({ left, width, height, type, message }: ConflictOverlayProps) {
const colors = {
availability: "bg-red-400/30 border-red-400",
overlap: "bg-orange-400/30 border-orange-400",
budget: "bg-yellow-400/30 border-yellow-400",
};
return (
<div
className={clsx(
"absolute border-2 rounded-md pointer-events-none",
"flex items-center justify-center",
colors[type],
)}
style={{ left, width, height, top: 4 }}
title={message}
>
<span className="text-xs font-medium text-red-700 bg-white/80 px-1 rounded">
{type === "availability" ? "⚡ conflict" : type === "overlap" ? "⚠ overlap" : "💰 over budget"}
</span>
</div>
);
});
@@ -0,0 +1,254 @@
"use client";
import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { AllocationStatus } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
interface NewAllocationPopoverProps {
resourceId: string;
startDate: Date;
endDate: Date;
/** Pre-selected project (from project-view sub-row context) */
suggestedProjectId?: string | null;
anchorX: number;
anchorY: number;
onClose: () => void;
onCreated: () => void;
}
function toDateInput(d: Date): string {
return d.toISOString().split("T")[0] ?? "";
}
export function NewAllocationPopover({
resourceId,
startDate,
endDate,
suggestedProjectId,
anchorX,
anchorY,
onClose,
onCreated,
}: NewAllocationPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils();
const [search, setSearch] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
suggestedProjectId ?? null,
);
const [role, setRole] = useState("Team Member");
const [hoursPerDay, setHoursPerDay] = useState(8);
const [start, setStart] = useState(toDateInput(startDate));
const [end, setEnd] = useState(toDateInput(endDate));
const [dropdownOpen, setDropdownOpen] = useState(!suggestedProjectId);
const { data: projectsData } = trpc.project.list.useQuery(
{ search, limit: 20 },
{ staleTime: 30_000 },
);
const projects = projectsData?.projects ?? [];
const selectedProject = projects.find((p) => p.id === selectedProjectId)
?? (suggestedProjectId ? projects.find((p) => p.id === suggestedProjectId) : null);
const createMutation = trpc.timeline.quickAssign.useMutation({
onSuccess: () => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
onCreated();
onClose();
},
});
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
function handleCreate() {
if (!selectedProjectId) return;
createMutation.mutate({
resourceId,
projectId: selectedProjectId,
startDate: new Date(start),
endDate: new Date(end),
hoursPerDay,
role,
status: AllocationStatus.PROPOSED,
});
}
const canCreate = !!selectedProjectId && !!start && !!end && hoursPerDay > 0;
const left = Math.min(anchorX - 10, typeof window !== "undefined" ? window.innerWidth - 340 : anchorX);
const top = Math.min(anchorY + 8, typeof window !== "undefined" ? window.innerHeight - 440 : anchorY);
const ORDER_COLORS: Record<string, string> = {
CHARGEABLE: "bg-emerald-100 text-emerald-700",
INTERNAL: "bg-blue-100 text-blue-700",
BD: "bg-violet-100 text-violet-700",
OVERHEAD: "bg-gray-100 text-gray-600",
};
return (
<div
ref={ref}
style={{ position: "fixed", left, top, zIndex: 60, width: 320 }}
className="bg-white border border-gray-200 rounded-xl shadow-2xl overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
<span className="text-sm font-semibold text-gray-700">Assign to Project</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
</div>
<div className="p-4 space-y-3">
{/* Date range */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Start</label>
<DateInput
value={start}
onChange={setStart}
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">End</label>
<DateInput
value={end}
onChange={setEnd}
min={start}
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
{/* Project picker */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Project</label>
{selectedProject && !dropdownOpen ? (
<div
className="flex items-center gap-2 border border-brand-300 rounded-lg px-3 py-2 cursor-pointer bg-brand-50"
onClick={() => { setDropdownOpen(true); setSearch(""); }}
>
<span className="text-sm text-gray-800 truncate flex-1">{selectedProject.name}</span>
<span className="text-xs text-gray-400"></span>
</div>
) : (
<div className="relative">
<input
autoFocus={dropdownOpen}
type="text"
placeholder="Search projects…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => setDropdownOpen(true)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
{dropdownOpen && projects.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 bg-white border border-gray-200 rounded-xl shadow-lg mt-1 max-h-44 overflow-y-auto">
{projects.map((p) => (
<button
key={p.id}
type="button"
onClick={() => { setSelectedProjectId(p.id); setDropdownOpen(false); setSearch(""); }}
className="w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2 border-b border-gray-50 last:border-0"
>
<span className="text-sm text-gray-800 truncate">{p.name}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
{/* Role */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Role</label>
<input
type="text"
value={role}
onChange={(e) => setRole(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
{/* Hours per day */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Hours / day</label>
<div className="flex items-center gap-2">
<input
type="number"
min={0.5}
max={24}
step={0.5}
value={hoursPerDay}
onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
className="w-24 border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
<div className="flex gap-1">
{[4, 6, 8].map((h) => (
<button
key={h}
type="button"
onClick={() => setHoursPerDay(h)}
className={clsx(
"px-2 py-1 rounded text-xs font-medium border transition-colors",
hoursPerDay === h
? "bg-brand-600 text-white border-brand-600"
: "border-gray-200 text-gray-600 hover:bg-gray-50",
)}
>
{h}h
</button>
))}
</div>
</div>
</div>
{/* Overbooking notice */}
<p className="text-xs text-amber-600 bg-amber-50 px-3 py-2 rounded-lg">
Overlapping allocations are allowed resource may be overbooked.
</p>
{/* Error */}
{createMutation.isError && (
<p className="text-xs text-red-600">{createMutation.error.message}</p>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
onClick={handleCreate}
disabled={!canCreate || createMutation.isPending}
className={clsx(
"flex-1 py-2 rounded-lg text-sm font-medium transition-colors",
"bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-40 disabled:cursor-not-allowed",
)}
>
{createMutation.isPending ? "Creating…" : "Assign"}
</button>
<button
onClick={onClose}
className="flex-1 py-2 rounded-lg text-sm font-medium border border-gray-200 text-gray-600 hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,535 @@
"use client";
import { clsx } from "clsx";
import { useState } from "react";
import { AllocationStatus, type StaffingRequirement } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js";
interface ProjectPanelProps {
projectId: string;
onClose: () => void;
}
interface DemandSummary {
id: string;
role: string;
hoursPerDay: number;
requestedHeadcount: number;
}
interface ProjectPanelAssignment {
id: string;
entityId?: string;
sourceAllocationId?: string;
resourceId: string;
role: string | null;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
metadata: { includeSaturday?: boolean } | null;
resource?: {
displayName: string;
eid: string;
} | null;
}
interface ProjectPanelDemand {
id: string;
entityId?: string;
sourceAllocationId?: string;
role: string | null;
hoursPerDay: number;
requestedHeadcount: number;
roleEntity?: {
name: string;
} | null;
}
interface ProjectPanelProject {
name: string;
orderType?: string;
status?: string;
startDate: Date | string;
endDate: Date | string;
budgetCents: number;
staffingReqs?: unknown;
}
interface ProjectPanelContext {
project: ProjectPanelProject;
assignments?: ProjectPanelAssignment[];
demands?: ProjectPanelDemand[];
}
interface ProjectPanelResource {
id: string;
displayName: string;
eid: string;
chapter?: string | null;
}
const STATUS_COLORS = {
green: "bg-green-500",
amber: "bg-amber-400",
red: "bg-red-500",
};
function toDateInput(d: Date | string): string {
return new Date(d).toISOString().split("T")[0] ?? "";
}
function normalizeRole(value: string | null | undefined): string {
return (value ?? "").trim().toLowerCase();
}
export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
const utils = trpc.useUtils();
const { data: ctx, isLoading } = trpc.timeline.getProjectContext.useQuery(
{ projectId },
{ staleTime: 5_000 },
);
const { data: budgetStatus } = trpc.timeline.getBudgetStatus.useQuery(
{ projectId },
{ staleTime: 5_000 },
);
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => {
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
},
});
const deleteMutation = trpc.allocation.deleteAssignment.useMutation({
onSuccess: () => {
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
},
});
const createAssignmentMutation = trpc.allocation.createAssignment.useMutation({
onSuccess: () => {
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
setAddingMember(false);
setResourceSearch("");
},
});
const [addingMember, setAddingMember] = useState(false);
const [resourceSearch, setResourceSearch] = useState("");
const [pendingEdits, setPendingEdits] = useState<
Record<string, { hoursPerDay?: number; startDate?: string; endDate?: string; includeSaturday?: boolean; role?: string }>
>({});
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const { data: allResources } = trpc.resource.list.useQuery(
{ search: resourceSearch },
{ enabled: addingMember, staleTime: 10_000 },
);
if (isLoading || !ctx) {
return (
<PanelShell onClose={onClose}>
<div className="flex items-center justify-center h-64 text-gray-400 text-sm">Loading</div>
</PanelShell>
);
}
const { project, assignments = [], demands = [] } = ctx as unknown as ProjectPanelContext;
const staffingReqs = (project.staffingReqs as unknown as StaffingRequirement[]) ?? [];
const effectiveAssignments = assignments as unknown as ProjectPanelAssignment[];
const projectDemands = demands as unknown as ProjectPanelDemand[];
const effectiveDemands: DemandSummary[] = projectDemands.length > 0
? projectDemands.map((demand) => ({
id: demand.id,
role: demand.roleEntity?.name ?? demand.role ?? "Unassigned",
hoursPerDay: demand.hoursPerDay,
requestedHeadcount: demand.requestedHeadcount,
}))
: staffingReqs.map((req, index) => ({
id: `staffing-${index}`,
role: req.role,
hoursPerDay: req.hoursPerDay,
requestedHeadcount: req.headcount,
}));
// Demand vs supply matching
const reqMatches = effectiveDemands.map((demand) => {
const demandRole = normalizeRole(demand.role);
const matched = effectiveAssignments.filter((assignment) =>
normalizeRole(assignment.role).includes(demandRole),
);
const totalHeadcount = matched.length;
const fulfilled = totalHeadcount >= demand.requestedHeadcount;
const partial = !fulfilled && totalHeadcount > 0;
return { demand, matched, fulfilled, partial };
});
const unmatchedAssignments = effectiveAssignments.filter(
(assignment) =>
!effectiveDemands.some((demand) =>
normalizeRole(assignment.role).includes(normalizeRole(demand.role)),
),
);
// Budget bar
const budgetEUR = (project.budgetCents / 100).toFixed(0);
const allocatedEUR = budgetStatus ? (budgetStatus.allocatedCents / 100).toFixed(0) : "—";
const utilPct = budgetStatus?.utilizationPercent ?? 0;
const budgetBarColor =
utilPct >= 100 ? STATUS_COLORS.red : utilPct >= 85 ? STATUS_COLORS.amber : STATUS_COLORS.green;
function getEdit(id: string) {
return pendingEdits[id] ?? {};
}
function setEdit(id: string, patch: typeof pendingEdits[string]) {
setPendingEdits((prev) => ({ ...prev, [id]: { ...(prev[id] ?? {}), ...patch } }));
}
function saveEdit(allocId: string) {
const edit = getEdit(allocId);
const alloc = effectiveAssignments.find((a) => a.id === allocId);
if (!alloc) return;
updateMutation.mutate({
allocationId: getPlanningEntryMutationId(alloc),
hoursPerDay: edit.hoursPerDay,
startDate: edit.startDate ? new Date(edit.startDate) : undefined,
endDate: edit.endDate ? new Date(edit.endDate) : undefined,
includeSaturday: edit.includeSaturday,
role: edit.role,
});
setPendingEdits((prev) => {
const next = { ...prev };
delete next[allocId];
return next;
});
}
function handleAddMember(resourceId: string) {
createAssignmentMutation.mutate({
resourceId,
projectId,
startDate: new Date(project.startDate),
endDate: new Date(project.endDate),
hoursPerDay: 8,
percentage: 100,
role: "Team Member",
status: AllocationStatus.PROPOSED,
metadata: {},
});
}
const availableResources = (allResources?.resources ?? []) as unknown as ProjectPanelResource[];
const filteredResources = availableResources.filter(
(r) => !effectiveAssignments.some((a) => a.resourceId === r.id),
);
return (
<PanelShell onClose={onClose}>
{/* Header */}
<div className="px-5 py-4 border-b border-gray-100">
<div className="flex items-start justify-between gap-2">
<div>
<h2 className="text-base font-semibold text-gray-900">{project.name}</h2>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={clsx(
"px-2 py-0.5 rounded-full text-xs font-medium",
project.orderType === "CHARGEABLE" ? "bg-emerald-100 text-emerald-700" :
project.orderType === "BD" ? "bg-violet-100 text-violet-700" :
project.orderType === "INTERNAL" ? "bg-blue-100 text-blue-700" :
"bg-gray-100 text-gray-600",
)}>
{project.orderType}
</span>
<span className="text-xs text-gray-400">{project.status}</span>
</div>
</div>
<div className="text-xs text-gray-400 mt-1">
{toDateInput(project.startDate)} {toDateInput(project.endDate)}
</div>
</div>
<div className="overflow-y-auto flex-1 px-5 py-4 space-y-6">
{/* Budget section */}
<section>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Budget</h3>
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Allocated</span>
<span className="font-semibold text-gray-900">{allocatedEUR} / {budgetEUR}</span>
</div>
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={clsx("h-full rounded-full transition-all", budgetBarColor)}
style={{ width: `${Math.min(utilPct, 100)}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-400">
<span>{utilPct.toFixed(1)}% utilized</span>
{budgetStatus && (
<span>{(budgetStatus.remainingCents / 100).toFixed(0)} remaining</span>
)}
</div>
{budgetStatus?.warnings.map((w, i) => (
<div
key={i}
className={clsx(
"text-xs px-2 py-1 rounded-lg",
w.level === "critical" ? "bg-red-50 text-red-700" :
w.level === "warning" ? "bg-amber-50 text-amber-700" :
"bg-blue-50 text-blue-700",
)}
>
{w.message}
</div>
))}
</div>
</section>
{/* Demand vs Supply */}
{effectiveDemands.length > 0 && (
<section>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
Demand vs Supply
</h3>
<div className="space-y-2">
{reqMatches.map(({ demand, matched, fulfilled, partial }) => (
<div key={demand.id} className="border border-gray-100 rounded-xl overflow-hidden">
<div className={clsx(
"flex items-center justify-between px-3 py-2",
fulfilled ? "bg-green-50" : partial ? "bg-amber-50" : "bg-red-50",
)}>
<div>
<span className="text-sm font-medium text-gray-800">{demand.role}</span>
<span className="ml-2 text-xs text-gray-500">{demand.requestedHeadcount} needed · {demand.hoursPerDay}h/day</span>
</div>
<span className={clsx(
"text-xs font-semibold",
fulfilled ? "text-green-600" : partial ? "text-amber-600" : "text-red-600",
)}>
{fulfilled ? "✓ Filled" : partial ? `${matched.length}/${demand.requestedHeadcount}` : "Unfilled"}
</span>
</div>
{matched.length > 0 && (
<div className="px-3 py-1.5 bg-white border-t border-gray-100 space-y-0.5">
{matched.map((a) => (
<div key={a.id} className="flex items-center justify-between text-xs text-gray-600">
<span>{a.resource?.displayName}</span>
<span className="text-gray-400">{a.hoursPerDay}h/day</span>
</div>
))}
</div>
)}
</div>
))}
</div>
{unmatchedAssignments.length > 0 && (
<div className="mt-2">
<div className="text-xs text-gray-400 mb-1">Unmatched assignments</div>
{unmatchedAssignments.map((a) => (
<div key={a.id} className="text-xs text-gray-500 flex justify-between">
<span>{a.resource?.displayName} {a.role}</span>
<span>{a.hoursPerDay}h/day</span>
</div>
))}
</div>
)}
</section>
)}
{/* Team */}
<section>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Team</h3>
<button
onClick={() => setAddingMember(true)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
+ Add Member
</button>
</div>
{/* Resource search for adding */}
{addingMember && (
<div className="mb-3 border border-gray-200 rounded-xl p-3 bg-gray-50 space-y-2">
<input
autoFocus
type="text"
placeholder="Search by name or EID…"
value={resourceSearch}
onChange={(e) => setResourceSearch(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
{filteredResources.slice(0, 8).map((r) => (
<button
key={r.id}
onClick={() => handleAddMember(r.id)}
disabled={createAssignmentMutation.isPending}
className="w-full text-left px-3 py-2 rounded-lg hover:bg-white text-sm text-gray-800 border border-transparent hover:border-gray-200 transition-colors"
>
<span className="font-medium">{r.displayName}</span>
<span className="text-gray-400 ml-2 text-xs">{r.eid}</span>
{r.chapter && <span className="text-gray-300 ml-1 text-xs">· {r.chapter}</span>}
</button>
))}
<button
onClick={() => { setAddingMember(false); setResourceSearch(""); }}
className="text-xs text-gray-400 hover:text-gray-600"
>
Cancel
</button>
</div>
)}
<div className="space-y-3">
{effectiveAssignments.map((alloc) => {
const edit = getEdit(alloc.id);
const isDirty = Object.keys(edit).length > 0;
const meta = alloc.metadata as { includeSaturday?: boolean } | null;
const inclSat = edit.includeSaturday ?? meta?.includeSaturday ?? false;
return (
<div
key={alloc.id}
className="border border-gray-100 rounded-xl p-3 space-y-2 bg-white"
>
{/* Resource name + delete */}
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-gray-800">{alloc.resource?.displayName}</span>
<span className="text-xs text-gray-400 ml-1.5">{alloc.resource?.eid}</span>
</div>
{confirmDelete === alloc.id ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-600">Remove?</span>
<button
onClick={() => { deleteMutation.mutate({ id: getPlanningEntryMutationId(alloc) }); setConfirmDelete(null); }}
className="text-xs text-red-600 font-medium hover:text-red-800"
>
Yes
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs text-gray-400 hover:text-gray-600"
>
No
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(alloc.id)}
className="text-xs text-red-400 hover:text-red-600"
>
Remove
</button>
)}
</div>
{/* Role */}
<input
type="text"
value={edit.role ?? alloc.role ?? ""}
onChange={(e) => setEdit(alloc.id, { role: e.target.value })}
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="Role"
/>
{/* Dates + hours */}
<div className="grid grid-cols-3 gap-2">
<div>
<label className="block text-[10px] text-gray-400 mb-0.5">Start</label>
<DateInput
value={edit.startDate ?? toDateInput(alloc.startDate)}
onChange={(v) => setEdit(alloc.id, { startDate: v })}
className="w-full border border-gray-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-[10px] text-gray-400 mb-0.5">End</label>
<DateInput
value={edit.endDate ?? toDateInput(alloc.endDate)}
onChange={(v) => setEdit(alloc.id, { endDate: v })}
className="w-full border border-gray-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-[10px] text-gray-400 mb-0.5">h/day</label>
<input
type="number"
min={0.5}
max={24}
step={0.5}
value={edit.hoursPerDay ?? alloc.hoursPerDay}
onChange={(e) => setEdit(alloc.id, { hoursPerDay: parseFloat(e.target.value) })}
className="w-full border border-gray-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
{/* Saturday toggle */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={inclSat}
onChange={(e) => setEdit(alloc.id, { includeSaturday: e.target.checked })}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-400"
/>
<span className="text-xs text-gray-600">Include Saturdays</span>
</label>
{/* Save button */}
{isDirty && (
<button
onClick={() => saveEdit(alloc.id)}
disabled={updateMutation.isPending}
className="w-full py-1.5 rounded-lg text-xs font-medium bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
>
{updateMutation.isPending ? "Saving…" : "Save changes"}
</button>
)}
</div>
);
})}
{effectiveAssignments.length === 0 && (
<div className="text-center py-8 text-sm text-gray-400">
No team members yet. Add one above.
</div>
)}
</div>
</section>
</div>
</PanelShell>
);
}
function PanelShell({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
return (
<div className="fixed inset-y-0 right-0 w-[420px] bg-white border-l border-gray-200 shadow-2xl z-40 flex flex-col">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
<span className="text-sm font-semibold text-gray-700">Project Details</span>
<button
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors text-lg leading-none"
>
&times;
</button>
</div>
<div className="flex flex-col flex-1 min-h-0">{children}</div>
</div>
);
}
@@ -0,0 +1,111 @@
"use client";
import { clsx } from "clsx";
import { memo } from "react";
import type { ShiftPreviewData } from "~/hooks/useTimelineDrag.js";
import { formatDate } from "~/lib/format.js";
import { usePermissions } from "~/hooks/usePermissions.js";
interface ShiftPreviewTooltipProps {
preview: ShiftPreviewData;
projectName: string;
newStartDate: Date;
newEndDate: Date;
isLoading?: boolean;
}
function formatCents(cents: number): string {
const abs = Math.abs(cents);
const str = (abs / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 });
return `${cents < 0 ? "" : "+"}${str}`;
}
export const ShiftPreviewTooltip = memo(function ShiftPreviewTooltip({
preview,
projectName,
newStartDate,
newEndDate,
isLoading,
}: ShiftPreviewTooltipProps) {
const { canViewCosts } = usePermissions();
const dateStr = `${formatDate(newStartDate)}${formatDate(newEndDate)}`;
return (
<div
className={clsx(
"bg-white border rounded-xl shadow-2xl p-3 min-w-56 max-w-72 text-sm",
preview.valid ? "border-gray-200" : "border-red-300",
)}
>
{/* Header */}
<div className="font-semibold text-gray-900 truncate mb-2">{projectName}</div>
<div className="text-xs text-gray-500 mb-3 font-mono">{dateStr}</div>
{isLoading ? (
<div className="text-xs text-gray-400 animate-pulse">Calculating...</div>
) : (
<>
{/* Cost delta */}
{canViewCosts && preview.deltaCents !== 0 && (
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-500">Cost delta</span>
<span
className={clsx(
"text-xs font-mono font-medium",
preview.deltaCents > 0 ? "text-red-600" : "text-green-600",
)}
>
{formatCents(preview.deltaCents)}
</span>
</div>
)}
{/* Budget utilization */}
{canViewCosts && (
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-gray-500">Budget after</span>
<span
className={clsx(
"text-xs font-medium",
preview.wouldExceedBudget
? "text-red-600"
: preview.budgetUtilizationAfter > 85
? "text-yellow-600"
: "text-gray-700",
)}
>
{preview.budgetUtilizationAfter.toFixed(1)}%
</span>
</div>
)}
{/* Conflicts */}
{preview.conflictCount > 0 && (
<div className="mb-2 text-xs text-yellow-700 bg-yellow-50 rounded-lg px-2 py-1.5">
{preview.conflictCount} availability conflict{preview.conflictCount > 1 ? "s" : ""}
</div>
)}
{/* Errors */}
{preview.errors.map((err, i) => (
<div key={i} className="mb-1 text-xs text-red-700 bg-red-50 rounded-lg px-2 py-1.5">
{err}
</div>
))}
{/* Warnings */}
{preview.warnings.slice(0, 2).map((warn, i) => (
<div key={i} className="mb-1 text-xs text-yellow-700 bg-yellow-50 rounded-lg px-2 py-1">
{warn}
</div>
))}
{/* Action hint */}
<div className="mt-2 text-xs text-gray-400 text-center">
{preview.valid ? "Release to apply shift" : "Cannot apply — resolve errors first"}
</div>
</>
)}
</div>
);
});
@@ -0,0 +1,388 @@
"use client";
import { clsx } from "clsx";
import { useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
export interface TimelineFilters {
chapters: string[];
/** Filter to specific resource EIDs */
eids: string[];
/** Filter to specific project IDs */
projectIds: string[];
showWeekends: boolean;
zoom: "day" | "week" | "month";
/**
* Hide allocations whose project has status COMPLETED or CANCELLED.
* Defaults to the user's global app preference; can be toggled per session.
*/
hideCompletedProjects: boolean;
/** Show DRAFT projects and their PROPOSED allocations on the timeline. */
showDrafts: boolean;
/** Show approved vacation blocks on resource rows. Default: true. */
showVacations: boolean;
/** Show open-demand entries (no resource assigned yet). Default: true. */
showPlaceholders: boolean;
}
export const DEFAULT_FILTERS: TimelineFilters = {
chapters: [],
eids: [],
projectIds: [],
showWeekends: false,
zoom: "day",
hideCompletedProjects: true, // overridden at runtime from AppPreferences
showDrafts: false,
showVacations: true,
showPlaceholders: true,
};
interface TimelineFilterProps {
filters: TimelineFilters;
onChange: (filters: TimelineFilters) => void;
isOpen: boolean;
onClose: () => void;
}
// ─── Chip ─────────────────────────────────────────────────────────────────────
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">
{label}
<button
type="button"
onClick={onRemove}
className="text-brand-400 hover:text-brand-700 leading-none"
>
×
</button>
</span>
);
}
// ─── EID picker ───────────────────────────────────────────────────────────────
function EidPicker({
selectedEids,
onChange,
}: {
selectedEids: string[];
onChange: (eids: string[]) => void;
}) {
const [search, setSearch] = useState("");
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data } = trpc.resource.list.useQuery(
{ search, isActive: true, limit: 200 },
{ 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));
function add(eid: string) {
onChange([...selectedEids, eid]);
setSearch("");
inputRef.current?.focus();
}
function remove(eid: string) {
onChange(selectedEids.filter((e) => e !== eid));
}
return (
<div>
<div className="flex flex-wrap gap-1 mb-1.5">
{selectedEids.map((eid) => (
<Chip key={eid} label={eid} onRemove={() => remove(eid)} />
))}
</div>
<div className="relative">
<input
ref={inputRef}
type="text"
placeholder="Search by name or EID…"
value={search}
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"
/>
{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"
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"
>
<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>}
</button>
))}
</div>
)}
</div>
</div>
);
}
// ─── Project picker ───────────────────────────────────────────────────────────
function ProjectPicker({
selectedIds,
onChange,
}: {
selectedIds: string[];
onChange: (ids: string[]) => void;
}) {
const [search, setSearch] = useState("");
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
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));
// Labels for selected chips — need to resolve names
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]));
function add(id: string) {
onChange([...selectedIds, id]);
setSearch("");
inputRef.current?.focus();
}
function remove(id: string) {
onChange(selectedIds.filter((i) => i !== id));
}
return (
<div>
<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)}
/>
);
})}
</div>
<div className="relative">
<input
ref={inputRef}
type="text"
placeholder="Search projects…"
value={search}
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"
/>
{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"
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"
>
<span className="text-gray-800 truncate">{p.name}</span>
</button>
))}
</div>
)}
</div>
</div>
);
}
// ─── Main filter panel ────────────────────────────────────────────────────────
export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineFilterProps) {
const { data: resourceData } = trpc.resource.list.useQuery({ isActive: true, limit: 500 });
const chapters = [
...new Set(
(resourceData?.resources as Array<{ chapter: string | null }> | undefined ?? []).map((r) => r.chapter).filter(Boolean) as string[],
),
].sort();
if (!isOpen) return null;
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">
<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>
)}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></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>
<div className="flex gap-2">
{(["day", "week", "month"] as const).map((z) => (
<button
key={z}
onClick={() => onChange({ ...filters, zoom: z })}
className={clsx(
"flex-1 px-2 py-1.5 text-xs rounded-lg border capitalize",
filters.zoom === z
? "bg-brand-50 border-brand-300 text-brand-700"
: "border-gray-200 text-gray-600 hover:bg-gray-50",
)}
>
{z}
</button>
))}
</div>
</div>
{/* EID filter */}
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
People (EID)
</label>
<EidPicker
selectedEids={filters.eids}
onChange={(eids) => onChange({ ...filters, eids })}
/>
</div>
{/* Project filter */}
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Projects
</label>
<ProjectPicker
selectedIds={filters.projectIds}
onChange={(projectIds) => onChange({ ...filters, projectIds })}
/>
</div>
{/* Chapters */}
{chapters.length > 0 && (
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Chapters
</label>
<div className="space-y-1 max-h-32 overflow-y-auto">
{chapters.map((ch) => (
<label key={ch} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={filters.chapters.includes(ch)}
onChange={(e) => {
const next = e.target.checked
? [...filters.chapters, ch]
: filters.chapters.filter((c) => c !== ch);
onChange({ ...filters, chapters: next });
}}
className="rounded border-gray-300"
/>
<span className="text-gray-700 dark:text-gray-300">{ch}</span>
</label>
))}
</div>
</div>
)}
{/* 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">
<input
type="checkbox"
checked={filters.showWeekends}
onChange={(e) => onChange({ ...filters, showWeekends: e.target.checked })}
className="rounded border-gray-300"
/>
<span className="text-gray-700 dark:text-gray-300">Show weekends</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={!filters.hideCompletedProjects}
onChange={(e) => onChange({ ...filters, hideCompletedProjects: !e.target.checked })}
className="rounded border-gray-300 mt-0.5"
/>
<span className="text-gray-700 dark:text-gray-300">
Show completed &amp; cancelled
<span className="block text-xs text-gray-400 font-normal">Default set in Preferences</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={filters.showDrafts}
onChange={(e) => onChange({ ...filters, showDrafts: e.target.checked })}
className="rounded border-gray-300 mt-0.5"
/>
<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>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={filters.showVacations}
onChange={(e) => onChange({ ...filters, showVacations: e.target.checked })}
className="rounded border-gray-300 mt-0.5"
/>
<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>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={filters.showPlaceholders}
onChange={(e) => onChange({ ...filters, showPlaceholders: e.target.checked })}
className="rounded border-gray-300 mt-0.5"
/>
<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>
</label>
</div>
<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"
>
Reset all filters
</button>
</div>
);
}
@@ -0,0 +1,110 @@
"use client";
import { clsx } from "clsx";
import { MONTHS_SHORT } from "./timelineConstants.js";
interface TimelineHeaderProps {
monthGroups: { label: string; colCount: number }[];
dates: Date[];
CELL_WIDTH: number;
LABEL_WIDTH: number;
HEADER_MONTH_HEIGHT: number;
HEADER_DAY_HEIGHT: number;
zoom: "day" | "week" | "month";
viewMode: "resource" | "project";
today: Date;
}
export function TimelineHeader({
monthGroups,
dates,
CELL_WIDTH,
LABEL_WIDTH,
HEADER_MONTH_HEIGHT,
HEADER_DAY_HEIGHT,
zoom,
viewMode,
today,
}: TimelineHeaderProps) {
return (
<>
{/* Month header */}
<div
className="sticky top-0 z-40 flex bg-white border-b border-gray-100"
style={{ height: HEADER_MONTH_HEIGHT }}
>
<div className="flex-shrink-0 border-r border-gray-200" style={{ width: LABEL_WIDTH }} />
<div className="flex">
{monthGroups.map((m, i) => (
<div
key={i}
className="text-xs font-semibold text-gray-500 border-r border-gray-200 px-2 flex items-center bg-gray-50"
style={{ width: m.colCount * CELL_WIDTH }}
>
{m.label}
</div>
))}
</div>
</div>
{/* Day header — hidden at month zoom (cells too narrow for labels) */}
{zoom !== "month" && (
<div
className="sticky z-40 flex bg-gray-50 border-b border-gray-200 select-none"
style={{ top: HEADER_MONTH_HEIGHT, height: HEADER_DAY_HEIGHT }}
>
<div
className="flex-shrink-0 border-r border-gray-200 flex items-center px-4 text-xs font-medium text-gray-400 uppercase tracking-wider"
style={{ width: LABEL_WIDTH }}
>
{viewMode === "resource" ? "Resource" : "Project / Resource"}
</div>
<div className="flex">
{dates.map((date, i) => {
const isToday = date.toDateString() === today.toDateString();
const isMonday = date.getDay() === 1;
const isSaturday = date.getDay() === 6;
const isSunday = date.getDay() === 0;
// Week zoom: show label only on Mondays to avoid overcrowding
const showLabel = zoom === "day" || isMonday;
return (
<div
key={i}
className={clsx(
"flex-shrink-0 border-r flex flex-col items-center justify-center text-xs overflow-hidden",
isToday ? "bg-brand-50 border-brand-200" :
isSaturday ? "bg-amber-50/60 border-amber-200" :
isSunday ? "bg-gray-100/80 border-gray-200" :
isMonday ? "border-gray-200" : "border-gray-100",
)}
style={{ width: CELL_WIDTH, height: HEADER_DAY_HEIGHT }}
>
{showLabel && (
<>
<span className={clsx(
"font-medium leading-none",
isToday ? "text-brand-600" : isSaturday ? "text-amber-600" : "text-gray-600",
)}>
{zoom === "week"
? `${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}`
: date.getDate()}
</span>
{zoom === "day" && (
<span className={clsx(
"text-[9px] leading-none mt-0.5",
isSaturday ? "text-amber-400" : "text-gray-300",
)}>
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][date.getDay()]}
</span>
)}
</>
)}
</div>
);
})}
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,154 @@
"use client";
import { clsx } from "clsx";
import { TimelineFilter, type TimelineFilters } from "./TimelineFilter.js";
interface TimelineToolbarProps {
viewMode: "resource" | "project";
onViewModeChange: (mode: "resource" | "project") => void;
filters: TimelineFilters;
onFiltersChange: (f: TimelineFilters) => void;
filterOpen: boolean;
onFilterOpenChange: (open: boolean) => void;
resourceCount: number;
projectCount: number;
totalAllocCount: number;
onNavigateBack: () => void;
onNavigateToday: () => void;
onNavigateForward: () => void;
canUndo?: boolean;
canRedo?: boolean;
onUndo?: () => void;
onRedo?: () => void;
}
export function TimelineToolbar({
viewMode,
onViewModeChange,
filters,
onFiltersChange,
filterOpen,
onFilterOpenChange,
resourceCount,
projectCount,
totalAllocCount,
onNavigateBack,
onNavigateToday,
onNavigateForward,
canUndo,
canRedo,
onUndo,
onRedo,
}: TimelineToolbarProps) {
const activeFilterCount = filters.chapters.length + filters.eids.length + filters.projectIds.length;
return (
<div className="flex items-center justify-between mb-2 gap-3">
<div className="text-sm text-gray-500">
{viewMode === "resource"
? `${resourceCount} resources · ${totalAllocCount} allocations`
: `${projectCount} projects`}
</div>
<div className="flex items-center gap-2">
{/* Timeline navigation */}
<div className="flex items-center gap-1">
<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"
title="Previous 4 weeks"
>
</button>
<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"
>
Today
</button>
<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"
title="Next 4 weeks"
>
</button>
</div>
{/* Undo / Redo */}
{(onUndo ?? onRedo) && (
<div className="flex items-center gap-1">
<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"
>
</button>
<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"
>
</button>
</div>
)}
{/* View mode toggle */}
<div className="flex rounded-lg border border-gray-200 overflow-hidden text-sm">
<button
onClick={() => onViewModeChange("resource")}
className={clsx(
"px-3 py-1.5 transition-colors",
viewMode === "resource"
? "bg-brand-600 text-white"
: "text-gray-600 hover:bg-gray-50",
)}
>
Resource view
</button>
<button
onClick={() => onViewModeChange("project")}
className={clsx(
"px-3 py-1.5 border-l border-gray-200 transition-colors",
viewMode === "project"
? "bg-brand-600 text-white"
: "text-gray-600 hover:bg-gray-50",
)}
>
Project view
</button>
</div>
{/* Filter */}
<div className="relative">
<button
onClick={() => onFilterOpenChange(!filterOpen)}
className={clsx(
"flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg border transition-colors",
filterOpen || activeFilterCount > 0
? "bg-brand-50 border-brand-300 text-brand-700"
: "border-gray-200 text-gray-600 hover:bg-gray-50",
)}
>
Filter
{activeFilterCount > 0 && (
<span className="w-4 h-4 rounded-full bg-brand-600 text-white text-xs flex items-center justify-center">
{activeFilterCount}
</span>
)}
</button>
<TimelineFilter
filters={filters}
onChange={onFiltersChange}
isOpen={filterOpen}
onClose={() => onFilterOpenChange(false)}
/>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
// ─── Heatmap colour palettes ───────────────────────────────────────────────────
// Each palette is [minPct, overlayRgba, barRgba] — overlay is semi-transparent
// (for heatmap mode strips), bar is more opaque (for bar-mode project view).
export const HEATMAP_PALETTES: Record<HeatmapColorScheme, [number, string, string][]> = {
"green-red": [
[0, "rgba(34,197,94,0.18)", "rgba(34,197,94,0.70)"],
[25, "rgba(132,204,22,0.25)", "rgba(132,204,22,0.75)"],
[50, "rgba(250,204,21,0.32)", "rgba(250,204,21,0.80)"],
[75, "rgba(249,115,22,0.40)", "rgba(249,115,22,0.82)"],
[90, "rgba(239,68,68,0.48)", "rgba(239,68,68,0.85)"],
[100, "rgba(185,28,28,0.58)", "rgba(185,28,28,0.88)"],
[125, "rgba(109,40,217,0.64)", "rgba(109,40,217,0.88)"],
],
"blue-orange": [
[0, "rgba(56,189,248,0.22)", "rgba(56,189,248,0.70)"],
[25, "rgba(59,130,246,0.28)", "rgba(59,130,246,0.75)"],
[50, "rgba(251,191,36,0.35)", "rgba(251,191,36,0.80)"],
[75, "rgba(249,115,22,0.42)", "rgba(249,115,22,0.82)"],
[90, "rgba(239,68,68,0.50)", "rgba(239,68,68,0.85)"],
[100, "rgba(185,28,28,0.58)", "rgba(185,28,28,0.88)"],
[125, "rgba(109,40,217,0.64)", "rgba(109,40,217,0.88)"],
],
"purple-yellow": [
[0, "rgba(167,139,250,0.22)", "rgba(167,139,250,0.70)"],
[25, "rgba(139,92,246,0.28)", "rgba(139,92,246,0.75)"],
[50, "rgba(250,204,21,0.35)", "rgba(250,204,21,0.80)"],
[75, "rgba(245,158,11,0.42)", "rgba(245,158,11,0.82)"],
[90, "rgba(239,68,68,0.50)", "rgba(239,68,68,0.85)"],
[100, "rgba(185,28,28,0.58)", "rgba(185,28,28,0.88)"],
[125, "rgba(109,40,217,0.64)", "rgba(109,40,217,0.88)"],
],
"mono": [
[0, "rgba(156,163,175,0.18)", "rgba(156,163,175,0.60)"],
[25, "rgba(107,114,128,0.25)", "rgba(107,114,128,0.68)"],
[50, "rgba(75,85,99,0.30)", "rgba(75,85,99,0.74)"],
[75, "rgba(55,65,81,0.36)", "rgba(55,65,81,0.80)"],
[90, "rgba(31,41,55,0.42)", "rgba(31,41,55,0.85)"],
[100, "rgba(17,24,39,0.52)", "rgba(17,24,39,0.88)"],
[125, "rgba(0,0,0,0.60)", "rgba(0,0,0,0.90)"],
],
};
// pct = (totalHoursPerDay / 8) * 100. Returns rgba string or null for 0%.
// mode: "overlay" for heatmap strips, "bar" for solid bar fill.
export function heatmapColor(pct: number, scheme: HeatmapColorScheme, mode: "overlay" | "bar" = "overlay"): string | null {
if (pct <= 0) return null;
const palette = HEATMAP_PALETTES[scheme] ?? HEATMAP_PALETTES["green-red"];
let entry = palette[0]!;
for (const row of palette) {
if (pct > row[0]) entry = row;
else break;
}
return mode === "bar" ? entry[2] : entry[1];
}
// Legacy alias used by heatmap overlay (overlay mode, green-red default)
export function heatmapBgColor(pct: number, scheme: HeatmapColorScheme = "green-red"): string | null {
return heatmapColor(pct, scheme, "overlay");
}
@@ -0,0 +1,25 @@
// ─── Layout constants ──────────────────────────────────────────────────────────
export const ROW_HEIGHT = 52;
export const SUB_LANE_HEIGHT = 36;
export const HEADER_DAY_HEIGHT = 28;
export const HEADER_MONTH_HEIGHT = 24;
export const LABEL_WIDTH = 256;
export const PROJECT_HEADER_HEIGHT = 40;
export const MONTHS_SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
export const ORDER_TYPE_COLORS: Record<string, { bg: string; text: string; light: string }> = {
CHARGEABLE: { bg: "bg-emerald-500", text: "text-white", light: "bg-emerald-50 border-emerald-200 dark:bg-emerald-950 dark:border-emerald-800" },
INTERNAL: { bg: "bg-blue-500", text: "text-white", light: "bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-800" },
BD: { bg: "bg-violet-500", text: "text-white", light: "bg-violet-50 border-violet-200 dark:bg-violet-950 dark:border-violet-800" },
OVERHEAD: { bg: "bg-slate-400", text: "text-white", light: "bg-slate-50 border-slate-200 dark:bg-slate-800 dark:border-slate-700" },
};
export const ORDER_TYPE_BADGE: Record<string, string> = {
CHARGEABLE: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300",
INTERNAL: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300",
BD: "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300",
OVERHEAD: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300",
};
export const DONE_STATUSES = new Set(["COMPLETED", "CANCELLED"]);
+220
View File
@@ -0,0 +1,220 @@
// ─── Date helpers ─────────────────────────────────────────────────────────────
export function addDays(date: Date, days: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
export function daysBetween(a: Date, b: Date): number {
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
}
/**
* Converts a date to a left-pixel offset within the visible timeline.
* Accounts for weekend-skipping when showWeekends is false.
*/
export function dateToLeft(
date: Date,
viewStart: Date,
viewEnd: Date,
cellWidth: number,
showWeekends: boolean,
): number {
const clamped = date < viewStart ? viewStart : date > viewEnd ? viewEnd : date;
if (showWeekends) {
return daysBetween(viewStart, clamped) * cellWidth;
}
let count = 0;
const cur = new Date(viewStart);
cur.setHours(0, 0, 0, 0);
const target = new Date(clamped);
target.setHours(0, 0, 0, 0);
while (cur < target) {
const dow = cur.getDay();
if (dow !== 0 && dow !== 6) count++;
cur.setDate(cur.getDate() + 1);
}
return count * cellWidth;
}
/**
* Computes the pixel width of a date range within the visible timeline.
* Accounts for weekend-skipping when showWeekends is false.
*/
export function dateRangeToWidth(
start: Date,
end: Date,
viewStart: Date,
viewEnd: Date,
cellWidth: number,
showWeekends: boolean,
): number {
let count = 0;
const cur = new Date(start < viewStart ? viewStart : start);
cur.setHours(0, 0, 0, 0);
const endC = new Date(end > viewEnd ? viewEnd : end);
endC.setHours(0, 0, 0, 0);
while (cur <= endC) {
const dow = cur.getDay();
if (showWeekends || (dow !== 0 && dow !== 6)) count++;
cur.setDate(cur.getDate() + 1);
}
return count * cellWidth;
}
// ─── O(1) position cache ──────────────────────────────────────────────────────
/**
* Pre-computes a pixel-offset lookup table for the entire visible date range.
* Returns `toLeft` / `toWidth` with O(1) lookups instead of O(n) loops.
* Use inside a `useMemo` that depends on viewStart / viewDays / cellWidth / showWeekends.
*/
export function createDatePositionCache(
viewStart: Date,
viewDays: number,
cellWidth: number,
showWeekends: boolean,
): { toLeft: (date: Date) => number; toWidth: (start: Date, end: Date) => number } {
// offsetMap: day-start-timestamp → pixel left rankMap: same → 0-based visible-day index
const offsetMap = new Map<number, number>();
const rankMap = new Map<number, number>();
let rank = 0;
const cur = new Date(viewStart);
cur.setHours(0, 0, 0, 0);
const viewStartT = cur.getTime();
for (let i = 0; i < viewDays; i++) {
const dow = cur.getDay();
if (showWeekends || (dow !== 0 && dow !== 6)) {
const t = cur.getTime();
offsetMap.set(t, rank * cellWidth);
rankMap.set(t, rank);
rank++;
}
cur.setDate(cur.getDate() + 1);
}
const totalWidth = rank * cellWidth;
const viewEndT = cur.getTime(); // timestamp of the day AFTER the last visible day
function toLeft(date: Date): number {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const t = d.getTime();
if (t <= viewStartT) return 0;
if (t >= viewEndT) return totalWidth;
const cached = offsetMap.get(t);
if (cached !== undefined) return cached;
// Weekend when showWeekends=false: return the *next* visible day's offset.
// This matches the original dateToLeft which counts strictly-before business days,
// so Saturday gets the same offset as the following Monday.
const next = new Date(d);
for (let i = 1; i <= 6; i++) {
next.setDate(next.getDate() + 1);
if (next.getTime() >= viewEndT) return totalWidth;
const v = offsetMap.get(next.getTime());
if (v !== undefined) return v;
}
return totalWidth;
}
function toWidth(start: Date, end: Date): number {
const sNorm = new Date(start < viewStart ? viewStart : start);
sNorm.setHours(0, 0, 0, 0);
const eNorm = new Date(end);
eNorm.setHours(0, 0, 0, 0);
// Rank of the first visible day at-or-after sNorm
let sRank: number;
const sT = sNorm.getTime();
const rS = rankMap.get(sT);
if (rS !== undefined) {
sRank = rS;
} else {
sRank = rank; // default: past end → 0 width
const next = new Date(sNorm);
for (let i = 1; i <= 6; i++) {
next.setDate(next.getDate() + 1);
if (next.getTime() >= viewEndT) break;
const r = rankMap.get(next.getTime());
if (r !== undefined) { sRank = r; break; }
}
}
// Rank of the last visible day at-or-before eNorm
let eRank: number;
const eT = eNorm.getTime();
if (eT >= viewEndT) {
eRank = rank - 1; // clamp to last visible day
} else {
const rE = rankMap.get(eT);
if (rE !== undefined) {
eRank = rE;
} else {
eRank = sRank - 1; // default: no visible day → 0 width
const prev = new Date(eNorm);
for (let i = 1; i <= 6; i++) {
prev.setDate(prev.getDate() - 1);
if (prev.getTime() < viewStartT) break;
const r = rankMap.get(prev.getTime());
if (r !== undefined) { eRank = r; break; }
}
}
}
if (eRank < sRank) return 0;
return (eRank - sRank + 1) * cellWidth;
}
return { toLeft, toWidth };
}
// ─── Sub-lane computation ──────────────────────────────────────────────────────
export interface SubLaneAlloc {
id: string;
startDate: Date;
endDate: Date;
}
/**
* Greedy lane assignment for overlapping allocations.
* Returns a map of allocationId → lane index (0-based).
* Allocations are sorted by startDate then assigned to the first lane
* that doesn't overlap with the previous occupant.
*/
export function computeSubLanes(allocs: SubLaneAlloc[]): Map<string, number> {
const sorted = [...allocs].sort(
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(),
);
// laneEnds[i] = end date of the last allocation placed in lane i
const laneEnds: Date[] = [];
const result = new Map<string, number>();
for (const alloc of sorted) {
const start = new Date(alloc.startDate);
const end = new Date(alloc.endDate);
let placed = false;
for (let i = 0; i < laneEnds.length; i++) {
const laneEnd = laneEnds[i]!;
if (start > laneEnd) {
// Lane is free
laneEnds[i] = end;
result.set(alloc.id, i);
placed = true;
break;
}
}
if (!placed) {
result.set(alloc.id, laneEnds.length);
laneEnds.push(end);
}
}
return result;
}
@@ -0,0 +1,152 @@
"use client";
import { useEffect, useRef, useState } from "react";
export interface AutocompleteOption {
id: string;
label: string;
sub?: string; // secondary label shown in smaller text
}
interface AutocompleteInputProps {
options: AutocompleteOption[];
value: string; // selected id
onChange: (id: string) => void;
placeholder?: string;
className?: string;
}
function normalize(s: string) {
return s.toLowerCase().replace(/[-_\s]/g, "");
}
function scoreMatch(option: AutocompleteOption, query: string): number {
const q = normalize(query);
const label = normalize(option.label);
const sub = normalize(option.sub ?? "");
if (label.startsWith(q) || sub.startsWith(q)) return 2;
if (label.includes(q) || sub.includes(q)) return 1;
return 0;
}
export function AutocompleteInput({ options, value, onChange, placeholder = "Search…", className }: AutocompleteInputProps) {
const selected = options.find((o) => o.id === value);
const [input, setInput] = useState(selected?.label ?? "");
const [open, setOpen] = useState(false);
const [activeIdx, setActiveIdx] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Keep input text in sync when selected value changes externally
useEffect(() => {
setInput(selected?.label ?? "");
}, [selected?.label]);
const filtered = input.trim() === "" || (selected && input === selected.label)
? options.slice(0, 20)
: options
.map((o) => ({ o, score: scoreMatch(o, input) }))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ o }) => o)
.slice(0, 20);
function select(opt: AutocompleteOption) {
onChange(opt.id);
setInput(opt.label);
setOpen(false);
setActiveIdx(0);
}
function clear() {
onChange("");
setInput("");
setOpen(true);
setActiveIdx(0);
inputRef.current?.focus();
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx((i) => Math.min(i + 1, filtered.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx((i) => Math.max(i - 1, 0)); }
else if (e.key === "Enter") {
e.preventDefault();
const opt = filtered[activeIdx];
if (opt) select(opt);
} else if (e.key === "Escape") {
setOpen(false);
// Restore display text to match current selection
setInput(selected?.label ?? "");
}
}
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setInput(selected?.label ?? "");
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open, selected?.label]);
return (
<div ref={containerRef} className={`relative ${className ?? ""}`}>
<div className="relative">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
setOpen(true);
setActiveIdx(0);
// Clear selection if user edits away from selected label
if (selected && e.target.value !== selected.label) onChange("");
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full px-3 py-2 pr-7 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500"
/>
{value && (
<button
type="button"
onClick={clear}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 leading-none text-base"
aria-label="Clear"
>
×
</button>
)}
</div>
{open && filtered.length > 0 && (
<ul
className="absolute z-50 left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden max-h-56 overflow-y-auto"
onMouseDown={(e) => e.preventDefault()}
>
{filtered.map((opt, idx) => (
<li key={opt.id}>
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); select(opt); }}
className={`w-full text-left px-3 py-2 text-sm flex items-baseline gap-2 transition-colors ${
idx === activeIdx
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300"
: "text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
<span className="truncate">{opt.label}</span>
{opt.sub && <span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">{opt.sub}</span>}
</button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,53 @@
"use client";
import { clsx } from "clsx";
interface BatchAction {
label: string;
onClick: () => void;
variant?: "default" | "danger";
disabled?: boolean;
}
interface BatchActionBarProps {
count: number;
actions: BatchAction[];
onClear: () => void;
}
export function BatchActionBar({ count, actions, onClear }: BatchActionBarProps) {
if (count === 0) return null;
return (
<div className="fixed bottom-6 left-72 right-6 z-40 flex items-center gap-4 bg-gray-900 text-white px-5 py-3 rounded-xl shadow-2xl border border-gray-700">
<span className="text-sm font-medium shrink-0">
{count} selected
</span>
<div className="flex items-center gap-2 flex-1">
{actions.map((action) => (
<button
key={action.label}
type="button"
onClick={action.onClick}
disabled={action.disabled}
className={clsx(
"px-3 py-1.5 text-sm font-medium rounded-lg transition-colors disabled:opacity-50",
action.variant === "danger"
? "bg-red-600 hover:bg-red-700 text-white"
: "bg-white/10 hover:bg-white/20 text-white",
)}
>
{action.label}
</button>
))}
</div>
<button
type="button"
onClick={onClear}
className="text-sm text-gray-400 hover:text-white transition-colors"
>
Clear
</button>
</div>
);
}
@@ -0,0 +1,162 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import type { ColumnDef } from "@planarchy/shared";
interface ColumnTogglePanelProps {
allColumns: ColumnDef[];
visibleKeys: string[];
onSetVisible: (keys: string[]) => void;
defaultKeys: string[];
}
export function ColumnTogglePanel({
allColumns,
visibleKeys,
onSetVisible,
defaultKeys,
}: ColumnTogglePanelProps) {
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
const dragKey = useRef<string | null>(null);
function toggle(key: string) {
const col = allColumns.find((c) => c.key === key);
if (!col?.hideable) return; // always-visible columns can't be toggled
const next = visibleKeys.includes(key)
? visibleKeys.filter((k) => k !== key)
: [...visibleKeys, key];
onSetVisible(next);
}
function reset() {
onSetVisible(defaultKeys);
}
const reorder = useCallback((fromKey: string, toKey: string) => {
if (fromKey === toKey) return;
const next = [...visibleKeys];
const from = next.indexOf(fromKey);
const to = next.indexOf(toKey);
if (from === -1 || to === -1) return;
next.splice(from, 1);
next.splice(to, 0, fromKey);
onSetVisible(next);
}, [visibleKeys, onSetVisible]);
const builtins = allColumns.filter((c) => !c.isCustom);
const customs = allColumns.filter((c) => c.isCustom);
return (
<div className="relative" ref={panelRef}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
title="Toggle columns"
className={`p-1.5 rounded-lg border text-sm transition-colors ${
open
? "border-brand-400 bg-brand-50 text-brand-700"
: "border-gray-300 text-gray-500 hover:border-gray-400 hover:text-gray-700"
}`}
aria-label="Column visibility"
>
{/* columns icon */}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<rect x="1" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
<rect x="6" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
<rect x="11" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
</svg>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-52 bg-white border border-gray-200 rounded-xl shadow-xl py-2">
<div className="px-3 pb-1 flex items-center justify-between">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Columns</span>
<button
type="button"
onClick={reset}
className="text-xs text-brand-600 hover:text-brand-800"
>
Reset
</button>
</div>
<div className="max-h-72 overflow-y-auto">
{builtins.map((col) => {
const isVisible = visibleKeys.includes(col.key);
return (
<div
key={col.key}
draggable={col.hideable && isVisible}
onDragStart={() => { dragKey.current = col.key; }}
onDragOver={(e) => { e.preventDefault(); }}
onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 ${
!col.hideable ? "opacity-50" : "cursor-grab"
}`}
>
{col.hideable && isVisible && (
<span className="text-gray-300 text-xs select-none"></span>
)}
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={isVisible}
onChange={() => toggle(col.key)}
disabled={!col.hideable}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">{col.label}</span>
</label>
</div>
);
})}
{customs.length > 0 && (
<>
<div className="my-1 border-t border-gray-100" />
<p className="px-3 py-1 text-xs text-gray-400 font-medium">Custom Fields</p>
{customs.map((col) => {
const isVisible = visibleKeys.includes(col.key);
return (
<div
key={col.key}
draggable={isVisible}
onDragStart={() => { dragKey.current = col.key; }}
onDragOver={(e) => { e.preventDefault(); }}
onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-grab"
>
{isVisible && <span className="text-gray-300 text-xs select-none"></span>}
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={isVisible}
onChange={() => toggle(col.key)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">{col.label}</span>
</label>
</div>
);
})}
</>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,57 @@
"use client";
interface ConfirmDialogProps {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "default" | "danger";
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({
title,
message,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
variant = "default",
onConfirm,
onCancel,
}: ConfirmDialogProps) {
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
<div className="px-6 py-5">
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
<p className="text-sm text-gray-600">{message}</p>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-5">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
className={
variant === "danger"
? "px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
: "px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors"
}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,105 @@
"use client";
import { FieldType } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
import type { CustomFieldFilter } from "~/hooks/useFilters.js";
interface Props {
/** Filterable field definitions from global blueprints */
filterableFields: (BlueprintFieldDefinition & { blueprintName: string })[];
activeFilters: CustomFieldFilter[];
onSetFilter: (key: string, value: string, type: FieldType) => void;
}
const INPUT_CLS =
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white";
export function CustomFieldFilterBar({ filterableFields, activeFilters, onSetFilter }: Props) {
if (filterableFields.length === 0) return null;
function getValue(key: string) {
return activeFilters.find((f) => f.key === key)?.value ?? "";
}
return (
<div className="flex flex-wrap items-center gap-2 mt-2">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">Custom:</span>
{filterableFields.map((field) => {
const value = getValue(field.key);
if (field.type === FieldType.BOOLEAN) {
return (
<select
key={field.key}
value={value}
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
className={INPUT_CLS}
aria-label={field.label}
>
<option value="">{field.label}: any</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
);
}
if (field.type === FieldType.SELECT && field.options && field.options.length > 0) {
return (
<select
key={field.key}
value={value}
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
className={INPUT_CLS}
aria-label={field.label}
>
<option value="">{field.label}: any</option>
{field.options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label || opt.value}</option>
))}
</select>
);
}
if (field.type === FieldType.NUMBER) {
return (
<input
key={field.key}
type="number"
value={value}
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
placeholder={field.label}
className={`${INPUT_CLS} w-32`}
aria-label={field.label}
/>
);
}
if (field.type === FieldType.DATE) {
return (
<input
key={field.key}
type="date"
value={value}
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
className={INPUT_CLS}
aria-label={field.label}
/>
);
}
// TEXT, TEXTAREA, URL, EMAIL, MULTI_SELECT — text search
return (
<input
key={field.key}
type="text"
value={value}
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
placeholder={field.label}
className={`${INPUT_CLS} w-40`}
aria-label={field.label}
/>
);
})}
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
/**
* DateInput — always displays dd/mm/yyyy regardless of browser/OS locale.
*
* - Text field shows and accepts dd/mm/yyyy (auto-inserts slashes while typing)
* - Calendar icon opens a hidden <input type="date"> for native picker
* - Internal value / onChange contract: yyyy-mm-dd strings (same as <input type="date">)
*/
import { useState, useEffect, useRef } from "react";
import { clsx } from "clsx";
// ── Conversion helpers ────────────────────────────────────────────────────────
function isoToDisplay(iso: string): string {
if (!iso) return "";
const [y, m, d] = iso.split("-");
if (!y || !m || !d) return "";
return `${d}/${m}/${y}`;
}
function displayToISO(display: string): string {
const digits = display.replace(/\D/g, "");
if (digits.length !== 8) return "";
const d = digits.slice(0, 2);
const m = digits.slice(2, 4);
const y = digits.slice(4, 8);
if (parseInt(d) < 1 || parseInt(d) > 31) return "";
if (parseInt(m) < 1 || parseInt(m) > 12) return "";
if (parseInt(y) < 1900 || parseInt(y) > 2100) return "";
return `${y}-${m}-${d}`;
}
// Auto-insert slashes as the user types
function autoSlash(raw: string): string {
const digits = raw.replace(/\D/g, "").slice(0, 8);
if (digits.length <= 2) return digits;
if (digits.length <= 4) return `${digits.slice(0, 2)}/${digits.slice(2)}`;
return `${digits.slice(0, 2)}/${digits.slice(2, 4)}/${digits.slice(4)}`;
}
// ── Component ─────────────────────────────────────────────────────────────────
interface DateInputProps {
value: string; // yyyy-mm-dd (empty string = unset)
onChange: (v: string) => void; // called with yyyy-mm-dd when valid
id?: string;
className?: string;
required?: boolean;
min?: string; // yyyy-mm-dd
max?: string; // yyyy-mm-dd
disabled?: boolean;
}
export function DateInput({
value,
onChange,
id,
className,
required,
min,
max,
disabled,
}: DateInputProps) {
const [display, setDisplay] = useState(isoToDisplay(value));
const hiddenRef = useRef<HTMLInputElement>(null);
// Sync display when value is changed externally
useEffect(() => {
setDisplay(isoToDisplay(value));
}, [value]);
function handleTextChange(e: React.ChangeEvent<HTMLInputElement>) {
const formatted = autoSlash(e.target.value);
setDisplay(formatted);
const iso = displayToISO(formatted);
if (iso) onChange(iso);
}
function handleBlur() {
// Re-normalise display to canonical format on blur
const iso = displayToISO(display);
if (iso) {
setDisplay(isoToDisplay(iso));
} else if (display && display.replace(/\D/g, "").length < 8) {
// Incomplete — keep as-is so user can fix it
} else if (!display) {
// Cleared
setDisplay("");
}
}
function handleHiddenChange(e: React.ChangeEvent<HTMLInputElement>) {
const iso = e.target.value; // yyyy-mm-dd from native picker
if (iso) {
setDisplay(isoToDisplay(iso));
onChange(iso);
}
}
return (
<div className="relative flex items-center">
<input
id={id}
type="text"
inputMode="numeric"
placeholder="dd/mm/yyyy"
value={display}
onChange={handleTextChange}
onBlur={handleBlur}
disabled={disabled}
required={required}
className={clsx("pr-8", className)}
/>
{/* Hidden native date input driven by the calendar icon */}
<input
ref={hiddenRef}
type="date"
tabIndex={-1}
aria-hidden="true"
value={value}
min={min}
max={max}
onChange={handleHiddenChange}
className="sr-only absolute inset-0 w-full opacity-0 pointer-events-none"
/>
{/* Calendar icon — clicking opens the hidden picker */}
<button
type="button"
tabIndex={-1}
disabled={disabled}
aria-label="Open date picker"
onClick={() => hiddenRef.current?.showPicker()}
className="absolute right-2 text-gray-400 hover:text-gray-600 transition-colors disabled:opacity-40"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="16" y1="2" x2="16" y2="6" strokeWidth="2" strokeLinecap="round" />
<line x1="8" y1="2" x2="8" y2="6" strokeWidth="2" strokeLinecap="round" />
<line x1="3" y1="10" x2="21" y2="10" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
);
}
@@ -0,0 +1,71 @@
"use client";
import { useRef, useState } from "react";
interface DraggableTableRowProps {
id: string;
/** Shared ref across all rows in the table — holds the ID currently being dragged. */
dragRef: React.MutableRefObject<string | null>;
/**
* Called when another row is dropped onto this row.
* Receives the ID of the row that was dragged (not this row's ID).
*/
onDrop: (draggedId: string) => void;
children: React.ReactNode;
className?: string;
}
/**
* Table row with a left-side drag handle for manual row reordering.
*
* Usage: replace <tr> with <DraggableTableRow>, add <th className="w-8 px-2" /> as the
* first header cell. Each table shares one dragRef (useRef<string | null>(null)).
*/
export function DraggableTableRow({
id,
dragRef,
onDrop,
children,
className = "",
}: DraggableTableRowProps) {
const [isDragOver, setIsDragOver] = useState(false);
return (
<tr
className={`${className} ${isDragOver ? "border-t-2 border-brand-400" : ""}`}
onDragOver={(e) => {
e.preventDefault();
if (dragRef.current && dragRef.current !== id) setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragOver(false);
if (dragRef.current && dragRef.current !== id) {
// Pass the dragged ID so the caller knows what moved where
onDrop(dragRef.current);
}
dragRef.current = null;
}}
>
{/* Drag handle — left-most cell */}
<td
draggable
onDragStart={(e) => {
dragRef.current = id;
// Semi-transparent ghost
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={() => {
dragRef.current = null;
setIsDragOver(false);
}}
className="w-8 px-2 py-3 cursor-grab active:cursor-grabbing select-none text-gray-300 hover:text-gray-400 text-center"
title="Drag to reorder"
>
</td>
{children}
</tr>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
interface FilterBarProps {
children: React.ReactNode;
hasActiveFilters?: boolean;
onClearFilters?: () => void;
}
export function FilterBar({ children, hasActiveFilters, onClearFilters }: FilterBarProps) {
return (
<div className="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"
>
Clear filters
</button>
)}
</div>
);
}
@@ -0,0 +1,41 @@
export interface Chip {
label: string;
onRemove: () => void;
}
interface FilterChipsProps {
chips: Chip[];
onClearAll: () => void;
}
export function FilterChips({ chips, onClearAll }: FilterChipsProps) {
if (chips.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-2">
{chips.map((chip) => (
<span
key={chip.label}
className="inline-flex items-center gap-1 rounded-full bg-brand-50 text-brand-700 border border-brand-200 px-2.5 py-0.5 text-xs"
>
{chip.label}
<button
type="button"
onClick={chip.onRemove}
className="ml-0.5 hover:text-brand-900 transition-colors"
aria-label={`Remove filter: ${chip.label}`}
>
×
</button>
</span>
))}
<button
type="button"
onClick={onClearAll}
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
>
Clear all
</button>
</div>
);
}
@@ -0,0 +1,33 @@
"use client";
import { useRef, useEffect } from "react";
interface InfiniteScrollSentinelProps {
onVisible: () => void;
isLoading: boolean;
}
export function InfiniteScrollSentinel({ onVisible, isLoading }: InfiniteScrollSentinelProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting && !isLoading) onVisible();
},
{ threshold: 0.1 },
);
obs.observe(el);
return () => obs.disconnect();
}, [isLoading, onVisible]);
return (
<div ref={ref} className="h-6 flex items-center justify-center">
{isLoading && (
<div className="w-4 h-4 border-2 border-brand-500 border-t-transparent rounded-full animate-spin" />
)}
</div>
);
}
@@ -0,0 +1,96 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
interface InfoTooltipProps {
content: React.ReactNode;
/** Position relative to the trigger icon. Default: "top" */
position?: "top" | "bottom";
/** Extra width class, e.g. "w-72". Default: "w-60" */
width?: string;
}
/**
* Small icon that shows a tooltip on hover / focus.
* Rendered via a portal into document.body so it's never clipped by
* ancestor overflow:hidden containers (table cells, widget cards, etc.).
*/
export function InfoTooltip({ content, position = "top", width = "w-60" }: InfoTooltipProps) {
const [show, setShow] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const btnRef = useRef<HTMLButtonElement>(null);
function computeCoords() {
if (!btnRef.current) return;
const rect = btnRef.current.getBoundingClientRect();
if (position === "top") {
setCoords({
top: rect.top + window.scrollY - 8, // 8px gap + arrow
left: rect.left + window.scrollX + rect.width / 2,
});
} else {
setCoords({
top: rect.bottom + window.scrollY + 8,
left: rect.left + window.scrollX + rect.width / 2,
});
}
}
function handleShow() {
computeCoords();
setShow(true);
}
// Recompute on scroll/resize while shown so tooltip follows the trigger
useEffect(() => {
if (!show) return;
const update = () => computeCoords();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show]);
const tooltipStyle: React.CSSProperties =
position === "top"
? { position: "fixed", top: coords.top, left: coords.left, transform: "translate(-50%, -100%)" }
: { position: "fixed", top: coords.top, left: coords.left, transform: "translateX(-50%)" };
const arrowClass =
position === "top"
? "top-full border-t-gray-900 border-l-transparent border-r-transparent border-b-transparent border-l-4 border-r-4 border-t-4 border-b-0"
: "bottom-full border-b-gray-900 border-l-transparent border-r-transparent border-t-transparent border-l-4 border-r-4 border-b-4 border-t-0";
return (
<span className="relative inline-flex items-center">
<button
ref={btnRef}
type="button"
onMouseEnter={handleShow}
onMouseLeave={() => setShow(false)}
onFocus={handleShow}
onBlur={() => setShow(false)}
className="ml-1 w-3.5 h-3.5 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-300 text-[9px] font-bold flex items-center justify-center hover:bg-gray-300 dark:hover:bg-gray-500 cursor-help flex-shrink-0 leading-none"
aria-label="More information"
>
i
</button>
{show &&
createPortal(
<div
style={tooltipStyle}
className={`z-[9999] ${width} bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-xl pointer-events-none`}
>
{content}
<span className={`absolute left-1/2 -translate-x-1/2 w-0 h-0 ${arrowClass}`} />
</div>,
document.body,
)}
</span>
);
}
@@ -0,0 +1,69 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
/**
* Thin brand-colored progress bar at the top of the page.
* Animates to 100% on route change, then fades out.
* Pure CSS animation — no external dependency.
*/
export function NavProgressBar() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [visible, setVisible] = useState(false);
const [width, setWidth] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevPathRef = useRef<string | null>(null);
// Detect link clicks to start the bar early
useEffect(() => {
function handleClick(e: MouseEvent) {
const target = (e.target as Element).closest("a");
if (!target) return;
const href = target.getAttribute("href");
if (!href || href.startsWith("http") || href.startsWith("#") || href.startsWith("mailto")) return;
// Internal navigation — start bar
setVisible(true);
setWidth(60); // jump to 60% immediately, await route change for completion
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
// Complete bar when route actually changes
useEffect(() => {
const current = pathname + searchParams.toString();
if (prevPathRef.current !== null && prevPathRef.current !== current) {
// Route changed — complete the bar
setWidth(100);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setVisible(false);
setWidth(0);
}, 350);
}
prevPathRef.current = current;
}, [pathname, searchParams]);
// Cleanup
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
if (!visible && width === 0) return null;
return (
<div
aria-hidden="true"
className="fixed top-0 left-0 right-0 z-[9999] h-0.5 pointer-events-none"
>
<div
className="h-full bg-brand-500 transition-all ease-out"
style={{
width: `${width}%`,
transitionDuration: width === 100 ? "200ms" : "400ms",
opacity: visible ? 1 : 0,
}}
/>
</div>
);
}
@@ -0,0 +1,140 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import type { ProjectStatus } from "@planarchy/shared";
interface ProjectComboboxProps {
value: string | null;
onChange: (id: string | null) => void;
placeholder?: string;
disabled?: boolean;
status?: ProjectStatus;
className?: string;
}
export function ProjectCombobox({
value,
onChange,
placeholder = "Search project…",
disabled = false,
status,
className = "",
}: ProjectComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { data } = trpc.project.list.useQuery(
{ search: debouncedSearch || undefined, limit: 15, ...(status ? { status } : {}) },
{ enabled: open, staleTime: 30_000 },
);
const projects = data?.projects ?? [];
const { data: allData } = trpc.project.list.useQuery(
{ limit: 500 },
{ enabled: !!value && !open, staleTime: 60_000 },
);
const selectedLabel = useMemo(() => {
if (!value) return "";
const fromOpen = projects.find((p) => p.id === value);
if (fromOpen) return `${fromOpen.shortCode}${fromOpen.name}`;
const fromAll = allData?.projects.find((p) => p.id === value);
if (fromAll) return `${fromAll.shortCode}${fromAll.name}`;
return value;
}, [value, projects, allData]);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleOpen() {
if (disabled) return;
setOpen(true);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}
function select(id: string | null) {
onChange(id);
setOpen(false);
setSearch("");
}
return (
<div className={`relative ${className}`} ref={containerRef}>
<button
type="button"
onClick={handleOpen}
disabled={disabled}
className={`w-full text-left px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white disabled:opacity-50 disabled:cursor-not-allowed ${
open ? "border-brand-500 ring-2 ring-brand-500" : "hover:border-gray-400"
}`}
>
<span className={selectedLabel ? "text-gray-900" : "text-gray-400"}>
{selectedLabel || placeholder}
</span>
{value && !disabled && (
<span
role="button"
tabIndex={0}
onMouseDown={(e) => { e.stopPropagation(); select(null); }}
onKeyDown={(e) => { if (e.key === "Enter") select(null); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-lg leading-none"
aria-label="Clear"
>
×
</span>
)}
</button>
{open && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
<div className="p-2 border-b border-gray-100">
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Type to search…"
className="w-full px-2 py-1 text-sm border-0 outline-none"
/>
</div>
<ul className="max-h-52 overflow-y-auto py-1">
{projects.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-400">No results</li>
) : (
projects.map((p) => (
<li key={p.id}>
<button
type="button"
onMouseDown={() => select(p.id)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 ${
p.id === value ? "bg-brand-50 text-brand-700 font-medium" : "text-gray-700"
}`}
>
<span className="font-medium text-xs text-gray-400 mr-1.5">{p.shortCode}</span>
<span>{p.name}</span>
</button>
</li>
))
)}
</ul>
</div>
)}
</div>
);
}
@@ -0,0 +1,140 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useDebounce } from "~/hooks/useDebounce.js";
interface ResourceComboboxProps {
value: string | null;
onChange: (id: string | null) => void;
placeholder?: string;
disabled?: boolean;
isActive?: boolean;
className?: string;
}
export function ResourceCombobox({
value,
onChange,
placeholder = "Search resource…",
disabled = false,
isActive = true,
className = "",
}: ResourceComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { data } = trpc.resource.list.useQuery(
{ search: debouncedSearch || undefined, limit: 15, isActive },
{ enabled: open, staleTime: 30_000 },
);
const resources = data?.resources ?? [];
// Resolve display name for currently selected value
const { data: selectedData } = trpc.resource.list.useQuery(
{ search: undefined, limit: 500, isActive: undefined as unknown as boolean },
{ enabled: !!value && !open, staleTime: 60_000 },
);
const selectedLabel = useMemo(() => {
if (!value) return "";
const fromOpen = resources.find((r) => r.id === value);
if (fromOpen) return `${fromOpen.displayName} (${fromOpen.eid})`;
const fromSelected = selectedData?.resources.find((r) => r.id === value);
if (fromSelected) return `${fromSelected.displayName} (${fromSelected.eid})`;
return value;
}, [value, resources, selectedData]);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleOpen() {
if (disabled) return;
setOpen(true);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}
function select(id: string | null) {
onChange(id);
setOpen(false);
setSearch("");
}
return (
<div className={`relative ${className}`} ref={containerRef}>
<button
type="button"
onClick={handleOpen}
disabled={disabled}
className={`w-full text-left px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white disabled:opacity-50 disabled:cursor-not-allowed ${
open ? "border-brand-500 ring-2 ring-brand-500" : "hover:border-gray-400"
}`}
>
<span className={selectedLabel ? "text-gray-900" : "text-gray-400"}>
{selectedLabel || placeholder}
</span>
{value && !disabled && (
<span
role="button"
tabIndex={0}
onMouseDown={(e) => { e.stopPropagation(); select(null); }}
onKeyDown={(e) => { if (e.key === "Enter") select(null); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-lg leading-none"
aria-label="Clear"
>
×
</span>
)}
</button>
{open && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
<div className="p-2 border-b border-gray-100">
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Type to search…"
className="w-full px-2 py-1 text-sm border-0 outline-none"
/>
</div>
<ul className="max-h-52 overflow-y-auto py-1">
{resources.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-400">No results</li>
) : (
resources.map((r) => (
<li key={r.id}>
<button
type="button"
onMouseDown={() => select(r.id)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 ${
r.id === value ? "bg-brand-50 text-brand-700 font-medium" : "text-gray-700"
}`}
>
<span>{r.displayName}</span>
<span className="ml-1.5 text-xs text-gray-400">{r.eid}</span>
</button>
</li>
))
)}
</ul>
</div>
)}
</div>
);
}
@@ -0,0 +1,139 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
interface SkillTagInputProps {
value: string[];
onChange: (skills: string[]) => void;
placeholder?: string;
className?: string;
}
export function SkillTagInput({ value, onChange, placeholder = "Add skill…", className }: SkillTagInputProps) {
const [input, setInput] = useState("");
const [open, setOpen] = useState(false);
const [activeIdx, setActiveIdx] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { data: analytics } = trpc.resource.getSkillsAnalytics.useQuery(undefined, {
staleTime: 120_000,
});
const aggregated: { skill: string }[] = analytics?.aggregated ?? [];
const suggestions: string[] = aggregated
.map((a) => a.skill)
.filter((s) => !value.includes(s) && s.toLowerCase().includes(input.toLowerCase()))
.slice(0, 8);
function addSkill(skill: string) {
const trimmed = skill.trim();
if (trimmed && !value.includes(trimmed)) {
onChange([...value, trimmed]);
}
setInput("");
setOpen(false);
setActiveIdx(-1);
inputRef.current?.focus();
}
function removeSkill(skill: string) {
onChange(value.filter((s) => s !== skill));
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
if (activeIdx >= 0 && suggestions[activeIdx]) {
addSkill(suggestions[activeIdx]);
} else if (input.trim()) {
addSkill(input);
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIdx((i) => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIdx((i) => Math.max(i - 1, -1));
} else if (e.key === "Escape") {
setOpen(false);
setActiveIdx(-1);
} else if (e.key === "Backspace" && input === "" && value.length > 0) {
onChange(value.slice(0, -1));
}
}
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return (
<div ref={containerRef} className={`relative ${className ?? ""}`}>
{/* Tags + input row */}
<div
className="min-h-[42px] flex flex-wrap gap-1.5 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 cursor-text focus-within:ring-2 focus-within:ring-brand-500 focus-within:border-transparent"
onClick={() => inputRef.current?.focus()}
>
{value.map((skill) => (
<span
key={skill}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-brand-100 dark:bg-brand-900/40 text-brand-800 dark:text-brand-300 text-xs font-medium rounded-full"
>
{skill}
<button
type="button"
onClick={() => removeSkill(skill)}
className="hover:text-brand-600 dark:hover:text-brand-200 leading-none"
aria-label={`Remove ${skill}`}
>
×
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => { setInput(e.target.value); setOpen(true); setActiveIdx(-1); }}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ""}
className="flex-1 min-w-[120px] outline-none bg-transparent text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500"
/>
</div>
{/* Dropdown */}
{open && suggestions.length > 0 && (
<ul
className="absolute z-50 left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden max-h-48 overflow-y-auto"
onMouseDown={(e) => e.preventDefault()}
>
{suggestions.map((skill, idx) => (
<li key={skill}>
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); addSkill(skill); }}
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
idx === activeIdx
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300"
: "text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
{skill}
</button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,73 @@
import { InfoTooltip } from "./InfoTooltip.js";
interface SortIconProps {
dir: "asc" | "desc" | null;
}
function SortIcon({ dir }: SortIconProps) {
return (
<span className="inline-flex flex-col leading-none ml-0.5">
<svg width="8" height="12" viewBox="0 0 8 12" fill="none" aria-hidden>
{/* Up chevron */}
<path
d="M1 5L4 2L7 5"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={dir === "asc" ? "stroke-brand-600" : "stroke-gray-300"}
/>
{/* Down chevron */}
<path
d="M1 7L4 10L7 7"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={dir === "desc" ? "stroke-brand-600" : "stroke-gray-300"}
/>
</svg>
</span>
);
}
interface SortableColumnHeaderProps {
label: string;
field: string;
sortField: string | null;
sortDir: "asc" | "desc" | null;
onSort: (field: string) => void;
className?: string;
align?: "left" | "right" | "center";
tooltip?: string;
tooltipWidth?: string;
}
export function SortableColumnHeader({
label,
field,
sortField,
sortDir,
onSort,
className = "",
align = "left",
tooltip,
tooltipWidth,
}: SortableColumnHeaderProps) {
const activeDir = sortField === field ? sortDir : null;
const alignClass = align === "right" ? "justify-end" : align === "center" ? "justify-center" : "justify-start";
return (
<th className={`px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider ${className}`}>
<div className={`flex items-center gap-0.5 ${alignClass}`}>
<button
type="button"
onClick={() => onSort(field)}
className="flex items-center gap-0.5 hover:text-gray-700 transition-colors group"
>
<span>{label}</span>
<SortIcon dir={activeDir} />
</button>
{tooltip && <InfoTooltip content={tooltip} {...(tooltipWidth !== undefined ? { width: tooltipWidth } : {})} />}
</div>
</th>
);
}
@@ -0,0 +1,99 @@
"use client";
import { trpc } from "~/lib/trpc/client.js";
interface BalanceCardProps {
resourceId: string;
year?: number;
compact?: boolean;
}
export function BalanceCard({ resourceId, year = new Date().getFullYear(), compact = false }: BalanceCardProps) {
const { data: balance, isLoading } = trpc.entitlement.getBalance.useQuery(
{ resourceId, year },
{ staleTime: 30_000 },
);
if (isLoading) {
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 animate-pulse">
<div className="h-4 bg-gray-100 dark:bg-gray-700 rounded w-1/3 mb-3" />
<div className="h-8 bg-gray-100 dark:bg-gray-700 rounded w-1/2" />
</div>
);
}
if (!balance) return null;
const pct = balance.entitledDays > 0
? Math.round((balance.usedDays / balance.entitledDays) * 100)
: 0;
if (compact) {
return (
<div className="flex items-center gap-3 text-sm">
<span className="font-medium text-gray-900 dark:text-gray-100">{balance.remainingDays}d remaining</span>
<span className="text-gray-400 dark:text-gray-600">·</span>
<span className="text-gray-500 dark:text-gray-400">{balance.usedDays}d used of {balance.entitledDays}d</span>
{balance.pendingDays > 0 && (
<>
<span className="text-gray-400 dark:text-gray-600">·</span>
<span className="text-amber-600">{balance.pendingDays}d pending</span>
</>
)}
</div>
);
}
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
Vacation Balance {year}
</h3>
{balance.carryoverDays > 0 && (
<span className="text-xs text-gray-400 dark:text-gray-500">+{balance.carryoverDays}d carried over</span>
)}
</div>
<div className="grid grid-cols-4 gap-3">
<Stat label="Entitled" value={balance.entitledDays} color="text-gray-900" />
<Stat label="Used" value={balance.usedDays} color="text-gray-600" />
<Stat label="Pending" value={balance.pendingDays} color="text-amber-600" />
<Stat label="Remaining" value={balance.remainingDays} color={balance.remainingDays < 5 ? "text-red-600" : "text-emerald-600"} />
</div>
{/* Progress bar */}
<div className="relative h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-emerald-500 rounded-full transition-all"
style={{ width: `${Math.min(100, pct)}%` }}
/>
{balance.pendingDays > 0 && (
<div
className="absolute inset-y-0 bg-amber-400 rounded-full"
style={{
left: `${Math.min(100, pct)}%`,
width: `${Math.min(100 - pct, Math.round((balance.pendingDays / balance.entitledDays) * 100))}%`,
}}
/>
)}
</div>
{balance.sickDays > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500">
{balance.sickDays} sick day{balance.sickDays !== 1 ? "s" : ""} recorded (not deducted from annual leave)
</p>
)}
</div>
);
}
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="text-center">
<p className={`text-xl font-bold ${color}`}>{value}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{label}</p>
</div>
);
}
@@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
export function EntitlementManager() {
const [year, setYear] = useState(new Date().getFullYear());
const [bulkDays, setBulkDays] = useState(28);
const [bulkResult, setBulkResult] = useState<number | null>(null);
const utils = trpc.useUtils();
const bulkSetMutation = trpc.entitlement.bulkSet.useMutation({
onSuccess: async (data) => {
setBulkResult(data.updated);
await utils.entitlement.getBalance.invalidate();
},
});
const { data: summary, isLoading } = trpc.entitlement.getYearSummary.useQuery(
{ year },
{ staleTime: 30_000 },
);
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-5">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Vacation Entitlement Manager</h3>
{/* Controls */}
<div className="flex flex-wrap gap-4 items-end">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Year</label>
<input
type="number"
value={year}
onChange={(e) => setYear(parseInt(e.target.value, 10))}
min={2020}
max={2030}
className="w-24 px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Default Days (bulk set)</label>
<input
type="number"
value={bulkDays}
onChange={(e) => setBulkDays(parseInt(e.target.value, 10))}
min={0}
max={365}
className="w-24 px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
/>
</div>
<button
type="button"
onClick={() => {
setBulkResult(null);
bulkSetMutation.mutate({ year, entitledDays: bulkDays });
}}
disabled={bulkSetMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{bulkSetMutation.isPending ? "Setting…" : "Bulk Set All Resources"}
</button>
{bulkResult !== null && (
<span className="text-sm text-emerald-600 dark:text-emerald-400">Updated {bulkResult} resources</span>
)}
</div>
{/* Year summary table */}
<div className="overflow-hidden rounded-lg border border-gray-100 dark:border-gray-700">
{isLoading ? (
<div className="p-6 text-center text-sm text-gray-400">Loading</div>
) : !summary?.length ? (
<div className="p-6 text-center text-sm text-gray-400">No resources found.</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Resource</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chapter</th>
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Entitled <InfoTooltip content="Total vacation days granted to this resource for the selected year." />
</span>
</th>
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Carryover <InfoTooltip content="Unused days carried over from the previous year." />
</span>
</th>
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Used <InfoTooltip content="Days already consumed by APPROVED vacations that have passed." />
</span>
</th>
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Pending <InfoTooltip content="Days reserved by APPROVED future vacations not yet started." />
</span>
</th>
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Remaining <InfoTooltip content="Entitled + Carryover Used Pending. Shown in red if fewer than 5 days remain." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50 dark:divide-gray-700">
{summary.map((row) => (
<tr key={row.resourceId} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-2.5">
<span className="font-medium text-gray-900 dark:text-gray-100">{row.displayName}</span>
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">({row.eid})</span>
</td>
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400">{row.chapter ?? "—"}</td>
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.entitledDays}</td>
<td className="px-4 py-2.5 text-right text-gray-400 dark:text-gray-500">{row.carryoverDays}</td>
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.usedDays}</td>
<td className="px-4 py-2.5 text-right text-amber-600">{row.pendingDays}</td>
<td className={`px-4 py-2.5 text-right font-semibold ${row.remainingDays < 5 ? "text-red-600" : "text-emerald-600"}`}>
{row.remainingDays}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import { VacationStatus, VacationType } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { VacationModal } from "./VacationModal.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { BalanceCard } from "./BalanceCard.js";
import { VacationCalendar } from "./VacationCalendar.js";
const STATUS_BADGE: Record<string, string> = {
PENDING: "bg-amber-100 text-amber-700 dark:bg-yellow-900/30 dark:text-yellow-400",
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-green-900/30 dark:text-green-400",
REJECTED: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
CANCELLED: "bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400",
};
const TYPE_LABELS: Record<string, string> = {
ANNUAL: "Annual Leave",
SICK: "Sick Leave",
PUBLIC_HOLIDAY: "Public Holiday",
OTHER: "Other",
};
export function MyVacationsClient() {
const [showModal, setShowModal] = useState(false);
const utils = trpc.useUtils();
// Find resource linked to current user
const { data: myResource } = trpc.resource.getMyResource.useQuery(undefined, {
staleTime: 60_000,
});
const resourceId = myResource?.id;
const { data: vacations, isLoading, refetch } = trpc.vacation.list.useQuery(
{ resourceId, limit: 200 },
{ enabled: !!resourceId, staleTime: 15_000 },
);
const cancelMutation = trpc.vacation.cancel.useMutation({
onSuccess: async () => {
await utils.vacation.list.invalidate();
await utils.entitlement.getBalance.invalidate();
},
});
const vacationList = vacations ?? [];
return (
<div className="p-6 max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">My Vacations</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage your personal vacation requests</p>
</div>
<button
type="button"
onClick={() => setShowModal(true)}
disabled={!resourceId}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
+ Request Vacation
</button>
</div>
{!resourceId && (
<div className="rounded-xl bg-amber-50 border border-amber-200 p-4 text-sm text-amber-700">
Your account is not linked to a resource. Please contact an administrator.
</div>
)}
{/* Balance card */}
{resourceId && (
<BalanceCard resourceId={resourceId} />
)}
{/* Calendar */}
{resourceId && vacationList.length > 0 && (
<VacationCalendar vacations={vacationList} />
)}
{/* Vacation list */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-sm text-gray-400">Loading</div>
) : vacationList.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-400">No vacation requests yet.</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
Type <InfoTooltip content="ANNUAL = paid annual leave · SICK = sick leave · PUBLIC_HOLIDAY = public holiday · OTHER = other leave types." />
</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">Start</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">End</th>
<th className="text-right px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Days <InfoTooltip content="Calendar days from start to end date (inclusive). Shows 0.5 for half-day requests (½ indicator on start date)." />
</span>
</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
Status <InfoTooltip content="PENDING = awaiting manager approval · APPROVED = confirmed leave · REJECTED = declined by manager · CANCELLED = withdrawn by employee." />
</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
Note <InfoTooltip content="Your note on the request, or the manager's rejection reason if declined." />
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-50 dark:divide-gray-700">
{vacationList.map((v) => {
const start = new Date(v.startDate);
const end = new Date(v.endDate);
const days = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1;
const status = v.status as string;
const type = v.type as string;
const vWithExtra = v as unknown as { rejectionReason?: string | null; isHalfDay?: boolean };
return (
<tr key={v.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{TYPE_LABELS[type] ?? type}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{start.toLocaleDateString("en-GB")}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{end.toLocaleDateString("en-GB")}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{vWithExtra.isHalfDay ? "0.5" : days}</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[status] ?? ""}`}>
{status}
</span>
</td>
<td className="px-4 py-3 text-gray-400 dark:text-gray-500 text-xs max-w-[200px]">
{vWithExtra.rejectionReason ? (
<span className="text-red-500">{vWithExtra.rejectionReason}</span>
) : (v.note ?? "—")}
</td>
<td className="px-4 py-3 text-right">
{(status === VacationStatus.PENDING || status === VacationStatus.APPROVED) && (
<button
type="button"
onClick={() => cancelMutation.mutate({ id: v.id })}
disabled={cancelMutation.isPending}
className="text-xs text-gray-400 hover:text-red-600 underline disabled:opacity-50"
>
Cancel
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{showModal && resourceId && (
<VacationModal
resourceId={resourceId}
onClose={() => setShowModal(false)}
onSuccess={() => {
setShowModal(false);
void refetch();
void utils.entitlement.getBalance.invalidate();
}}
/>
)}
</div>
);
}
@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { GERMAN_FEDERAL_STATES } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
export function PublicHolidayBatch() {
const [year, setYear] = useState(new Date().getFullYear());
const [federalState, setFederalState] = useState("BY");
const [chapter, setChapter] = useState("");
const [replaceExisting, setReplaceExisting] = useState(false);
const [result, setResult] = useState<{ created: number; holidays?: number; resources?: number } | null>(null);
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const resourceList = (resources?.resources ?? []) as Array<{ id: string; chapter?: string | null }>;
const chapters = Array.from(
new Set(resourceList.map((r) => r.chapter).filter(Boolean) as string[])
).sort();
const mutation = trpc.vacation.batchCreatePublicHolidays.useMutation({
onSuccess: (data) => setResult(data),
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setResult(null);
mutation.mutate({
year,
federalState: federalState || undefined,
chapter: chapter || undefined,
replaceExisting,
});
}
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-4">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Batch Create Public Holidays</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Creates public holidays as APPROVED vacation entries for all resources (or a chapter).
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Year</label>
<input
type="number"
value={year}
onChange={(e) => setYear(parseInt(e.target.value, 10))}
min={2020}
max={2030}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Federal State</label>
<select
value={federalState}
onChange={(e) => setFederalState(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
>
<option value="">Federal only</option>
{Object.entries(GERMAN_FEDERAL_STATES).map(([abbr, name]) => (
<option key={abbr} value={abbr}>{name} ({abbr})</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Chapter (optional)</label>
<select
value={chapter}
onChange={(e) => setChapter(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
>
<option value="">All chapters</option>
{chapters.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={replaceExisting}
onChange={(e) => setReplaceExisting(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600 text-brand-600"
/>
Replace existing public holidays on those dates
</label>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={mutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{mutation.isPending ? "Creating…" : "Create Public Holidays"}
</button>
{result && (
<span className="text-sm text-emerald-600 dark:text-emerald-400">
Created {result.created} entries{result.holidays ? ` (${result.holidays} holidays × ${result.resources ?? 0} resources)` : ""}
</span>
)}
{mutation.error && (
<span className="text-sm text-red-500">{mutation.error.message}</span>
)}
</div>
</form>
</div>
);
}
@@ -0,0 +1,202 @@
"use client";
import { useState } from "react";
import { VacationStatus } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
const TYPE_COLOR: Record<string, string> = {
ANNUAL: "bg-brand-500",
SICK: "bg-red-400",
PUBLIC_HOLIDAY: "bg-emerald-500",
OTHER: "bg-purple-400",
};
const MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
function isoDate(d: Date | string): string {
const date = typeof d === "string" ? new Date(d) : d;
return date.toISOString().slice(0, 10);
}
export function TeamCalendar() {
const now = new Date();
const [month, setMonth] = useState(now.getMonth());
const [year, setYear] = useState(now.getFullYear());
const [chapter, setChapter] = useState<string>("");
const firstDay = new Date(Date.UTC(year, month, 1));
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500, ...(chapter ? { chapter } : {}) },
{ staleTime: 60_000 },
);
const { data: vacations } = trpc.vacation.list.useQuery(
{
startDate: firstDay,
endDate: new Date(Date.UTC(year, month + 1, 0)),
limit: 500,
},
{ staleTime: 15_000 },
);
// Distinct chapters for filter
const chapters = Array.from(
new Set((resources?.resources ?? []).map((r) => r.chapter).filter(Boolean) as string[])
).sort();
const resourceList = resources?.resources ?? [];
const vacationList = (vacations ?? []).filter(
(v) => v.status !== VacationStatus.CANCELLED && v.status !== VacationStatus.REJECTED,
);
// Build map: resourceId → date → vacation
const vacationMap = new Map<string, Map<string, { type: string; status: string }>>();
for (const v of vacationList) {
const start = isoDate(v.startDate);
const end = isoDate(v.endDate);
let cur = start;
while (cur <= end) {
const resourceVacs = vacationMap.get(v.resourceId) ?? new Map();
if (!resourceVacs.has(cur)) {
resourceVacs.set(cur, { type: v.type, status: v.status });
}
vacationMap.set(v.resourceId, resourceVacs);
// next day
const d = new Date(cur);
d.setUTCDate(d.getUTCDate() + 1);
cur = d.toISOString().slice(0, 10);
}
}
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
const today = now.toISOString().slice(0, 10);
function prevMonth() {
if (month === 0) { setMonth(11); setYear(y => y - 1); }
else setMonth(m => m - 1);
}
function nextMonth() {
if (month === 11) { setMonth(0); setYear(y => y + 1); }
else setMonth(m => m + 1);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-700 flex-wrap">
<button type="button" onClick={prevMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
</button>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 min-w-[120px] text-center">
{MONTH_NAMES[month]} {year}
</span>
<button type="button" onClick={nextMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
</button>
<div className="flex-1" />
<select
value={chapter}
onChange={(e) => setChapter(e.target.value)}
className="text-sm border border-gray-200 dark:border-gray-600 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
>
<option value="">All chapters</option>
{chapters.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
{/* Grid */}
<div className="overflow-x-auto">
<table className="min-w-full text-xs">
<thead>
<tr className="bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-36 border-r border-gray-100 dark:border-gray-700">
Resource
</th>
{days.map((d) => {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const dow = new Date(dateStr).getUTCDay(); // 0=Sun, 6=Sat
const isWeekend = dow === 0 || dow === 6;
const isToday = dateStr === today;
return (
<th
key={d}
className={`px-1 py-2 text-center font-medium w-7 ${isWeekend ? "text-gray-300 dark:text-gray-600 bg-gray-50 dark:bg-gray-900" : isToday ? "text-brand-700 bg-brand-50" : "text-gray-500 dark:text-gray-400"}`}
>
{d}
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-gray-50 dark:divide-gray-700">
{resourceList.map((r) => {
const rMap = vacationMap.get(r.id);
return (
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="sticky left-0 z-10 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 px-3 py-1.5 border-r border-gray-100 dark:border-gray-700 font-medium text-gray-800 dark:text-gray-100 truncate max-w-[9rem]">
{r.displayName}
</td>
{days.map((d) => {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const vac = rMap?.get(dateStr);
const dow = new Date(dateStr).getUTCDay();
const isWeekend = dow === 0 || dow === 6;
const isToday = dateStr === today;
let cellClass = "w-7 h-7";
if (vac) {
const color = TYPE_COLOR[vac.type] ?? "bg-gray-400";
const opacity = vac.status === "PENDING" ? "opacity-50" : "";
cellClass += ` ${color} ${opacity}`;
} else if (isWeekend) {
cellClass += " bg-gray-50 dark:bg-gray-900";
} else if (isToday) {
cellClass += " bg-brand-50";
}
return (
<td key={d} className="px-0.5 py-0.5">
<div
className={cellClass + " rounded-sm"}
title={vac ? `${r.displayName}: ${vac.type} (${vac.status})` : undefined}
/>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
{resourceList.length === 0 && (
<div className="p-8 text-center text-sm text-gray-400">No resources found.</div>
)}
</div>
{/* Legend */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
{Object.entries(TYPE_COLOR).map(([type, color]) => (
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className={`${color} w-3 h-3 rounded-sm inline-block`} />
{type.replace("_", " ")}
</span>
))}
<span className="flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
<span className="opacity-50 bg-brand-500 w-3 h-3 rounded-sm inline-block" />
Pending
</span>
</div>
</div>
);
}
@@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import { VacationStatus, VacationType } from "@planarchy/shared";
interface VacationEntry {
id: string;
startDate: Date | string;
endDate: Date | string;
type: string;
status: string;
resource?: { displayName: string; eid: string } | null;
}
interface VacationCalendarProps {
vacations: VacationEntry[];
year?: number;
initialMonth?: number; // 0-indexed
}
const TYPE_COLOR: Record<string, string> = {
ANNUAL: "bg-brand-500",
SICK: "bg-red-400",
PUBLIC_HOLIDAY: "bg-emerald-400",
OTHER: "bg-purple-400",
};
const STATUS_OPACITY: Record<string, string> = {
APPROVED: "opacity-100",
PENDING: "opacity-60",
REJECTED: "opacity-30",
CANCELLED: "opacity-20",
};
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
function isoDate(d: Date | string): string {
const date = typeof d === "string" ? new Date(d) : d;
return date.toISOString().slice(0, 10);
}
function addDays(dateStr: string, n: number): string {
const d = new Date(dateStr);
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
function getDatesInRange(start: Date | string, end: Date | string): Set<string> {
const dates = new Set<string>();
let cur = isoDate(start);
const last = isoDate(end);
while (cur <= last) {
dates.add(cur);
cur = addDays(cur, 1);
}
return dates;
}
export function VacationCalendar({ vacations, year = new Date().getFullYear(), initialMonth = new Date().getMonth() }: VacationCalendarProps) {
const [month, setMonth] = useState(initialMonth);
const [currentYear, setCurrentYear] = useState(year);
function prevMonth() {
if (month === 0) { setMonth(11); setCurrentYear(y => y - 1); }
else setMonth(m => m - 1);
}
function nextMonth() {
if (month === 11) { setMonth(0); setCurrentYear(y => y + 1); }
else setMonth(m => m + 1);
}
// Build a set of date → vacation entries for fast lookup
const dateMap = new Map<string, VacationEntry[]>();
for (const v of vacations) {
if ([VacationStatus.CANCELLED, VacationStatus.REJECTED].includes(v.status as VacationStatus)) continue;
const dates = getDatesInRange(v.startDate, v.endDate);
for (const d of dates) {
const existing = dateMap.get(d) ?? [];
existing.push(v);
dateMap.set(d, existing);
}
}
// Build calendar grid
const firstDay = new Date(Date.UTC(currentYear, month, 1));
const daysInMonth = new Date(Date.UTC(currentYear, month + 1, 0)).getUTCDate();
// ISO weekday: Mon=1, Sun=7 → index 0-6
const startOffset = (firstDay.getUTCDay() + 6) % 7; // Mon first
const cells: (number | null)[] = [
...Array<null>(startOffset).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
// Pad to complete last row
while (cells.length % 7 !== 0) cells.push(null);
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700">
<button type="button" onClick={prevMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
</button>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{MONTH_NAMES[month]} {currentYear}
</h3>
<button type="button" onClick={nextMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
</button>
</div>
{/* Day names */}
<div className="grid grid-cols-7 border-b border-gray-100 dark:border-gray-700">
{DAYS.map((d) => (
<div key={d} className="px-2 py-1.5 text-center text-xs font-medium text-gray-400 dark:text-gray-500">
{d}
</div>
))}
</div>
{/* Days grid */}
<div className="grid grid-cols-7">
{cells.map((day, idx) => {
if (!day) {
return <div key={`empty-${idx}`} className="p-1 min-h-[60px] border-b border-r border-gray-50 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-900/30" />;
}
const dateStr = `${currentYear}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const dayVacations = dateMap.get(dateStr) ?? [];
const today = new Date().toISOString().slice(0, 10);
const isToday = dateStr === today;
return (
<div
key={dateStr}
className={`p-1 min-h-[60px] border-b border-r border-gray-50 dark:border-gray-700 ${isToday ? "bg-brand-50" : ""}`}
>
<span className={`text-xs font-medium block mb-1 ${isToday ? "text-brand-700" : "text-gray-500 dark:text-gray-400"}`}>
{day}
</span>
<div className="space-y-0.5">
{dayVacations.slice(0, 3).map((v) => {
const colorClass = TYPE_COLOR[v.type] ?? "bg-gray-400";
const opacityClass = STATUS_OPACITY[v.status] ?? "opacity-100";
const name = v.resource?.displayName ?? "—";
return (
<div
key={v.id + dateStr}
className={`${colorClass} ${opacityClass} text-white text-xs px-1 rounded truncate`}
title={`${name}${v.type} (${v.status})`}
>
{name.split(" ")[0]}
</div>
);
})}
{dayVacations.length > 3 && (
<div className="text-xs text-gray-400 dark:text-gray-500 pl-1">+{dayVacations.length - 3}</div>
)}
</div>
</div>
);
})}
</div>
{/* Legend */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
{Object.entries(TYPE_COLOR).map(([type, color]) => (
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className={`${color} w-3 h-3 rounded-sm inline-block`} />
{type.replace("_", " ")}
</span>
))}
</div>
</div>
);
}
@@ -0,0 +1,458 @@
"use client";
import { useState } from "react";
import { VacationStatus, VacationType } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { VacationModal } from "./VacationModal.js";
import { TeamCalendar } from "./TeamCalendar.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const STATUS_BADGE: Record<VacationStatus, string> = {
PENDING: "bg-amber-100 text-amber-700 dark:bg-yellow-900/30 dark:text-yellow-400",
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-green-900/30 dark:text-green-400",
REJECTED: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
CANCELLED: "bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400",
};
const TYPE_LABELS: Record<VacationType, string> = {
ANNUAL: "Annual Leave",
SICK: "Sick Leave",
PUBLIC_HOLIDAY: "Public Holiday",
OTHER: "Other",
};
type VacationStatusFilter = VacationStatus | "ALL";
type VacationTypeFilter = VacationType | "ALL";
type Tab = "list" | "team-calendar";
export function VacationClient() {
const [tab, setTab] = useState<Tab>("list");
const [showModal, setShowModal] = useState(false);
const [statusFilter, setStatusFilter] = useState<VacationStatusFilter>("ALL");
const [typeFilter, setTypeFilter] = useState<VacationTypeFilter>("ALL");
const [resourceFilter, setResourceFilter] = useState<string>("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchRejectReason, setBatchRejectReason] = useState("");
const [showBatchRejectInput, setShowBatchRejectInput] = useState(false);
const { data: vacations, isLoading, error: vacationError, refetch } = trpc.vacation.list.useQuery(
{
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
...(typeFilter !== "ALL" ? { type: typeFilter } : {}),
...(resourceFilter ? { resourceId: resourceFilter } : {}),
limit: 200,
},
{ staleTime: 15_000 },
);
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const { data: pending } = trpc.vacation.getPendingApprovals.useQuery(undefined, {
staleTime: 15_000,
});
const utils = trpc.useUtils();
function invalidateAll() {
return Promise.all([
utils.vacation.list.invalidate(),
utils.vacation.getPendingApprovals.invalidate(),
utils.entitlement.getBalance.invalidate(),
]);
}
const approveMutation = trpc.vacation.approve.useMutation({ onSuccess: invalidateAll });
const rejectMutation = trpc.vacation.reject.useMutation({ onSuccess: invalidateAll });
const cancelMutation = trpc.vacation.cancel.useMutation({ onSuccess: () => utils.vacation.list.invalidate() });
const batchApproveMutation = trpc.vacation.batchApprove.useMutation({
onSuccess: async () => {
setSelected(new Set());
await invalidateAll();
},
});
const batchRejectMutation = trpc.vacation.batchReject.useMutation({
onSuccess: async () => {
setSelected(new Set());
setShowBatchRejectInput(false);
setBatchRejectReason("");
await invalidateAll();
},
});
const resourceList = (resources?.resources ?? []) as unknown as Array<{ id: string; displayName: string; eid: string }>;
const vacationList = vacations ?? [];
const pendingList = pending ?? [];
const vacViewPrefs = useViewPrefs("vacations");
const { sorted, sortField, sortDir, toggle } = useTableSort(vacationList, {
initialField: vacViewPrefs.savedSort?.field ?? null,
initialDir: vacViewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
vacViewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
function handleSort(field: string) {
if (field === "resource") {
toggle("resource", (v) => (v.resource as { displayName: string } | undefined)?.displayName ?? null);
} else {
toggle(field);
}
}
function clearAll() {
setStatusFilter("ALL");
setTypeFilter("ALL");
setResourceFilter("");
}
const selectedResourceName = resourceFilter ? resourceList.find((r) => r.id === resourceFilter)?.displayName : null;
const chips = [
...(statusFilter !== "ALL" ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("ALL") }] : []),
...(typeFilter !== "ALL" ? [{ label: `Type: ${TYPE_LABELS[typeFilter]}`, onRemove: () => setTypeFilter("ALL") }] : []),
...(resourceFilter ? [{ label: `Resource: ${selectedResourceName ?? resourceFilter}`, onRemove: () => setResourceFilter("") }] : []),
];
const pendingIds = pendingList.map((v) => v.id);
const selectedPending = [...selected].filter((id) => pendingIds.includes(id));
const allPendingSelected = pendingIds.length > 0 && pendingIds.every((id) => selected.has(id));
function toggleSelectAll() {
if (allPendingSelected) {
setSelected(new Set());
} else {
setSelected(new Set(pendingIds));
}
}
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Vacations</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage vacation requests and approvals</p>
</div>
<button
type="button"
onClick={() => setShowModal(true)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
+ Request Vacation
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200 dark:border-gray-700">
{(["list", "team-calendar"] as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === t
? "border-brand-600 text-brand-700"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
{t === "list" ? "List" : "Team Calendar"}
</button>
))}
</div>
{tab === "team-calendar" ? (
<TeamCalendar />
) : (
<>
{/* Pending approvals (manager view) */}
{pendingList.length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-amber-800">
Pending Approvals ({pendingList.length})
</h2>
{/* Batch controls */}
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs text-amber-700 cursor-pointer">
<input
type="checkbox"
checked={allPendingSelected}
onChange={toggleSelectAll}
className="rounded border-amber-300 text-amber-600"
/>
Select all
</label>
{selectedPending.length > 0 && (
<>
<button
type="button"
onClick={() => batchApproveMutation.mutate({ ids: selectedPending })}
disabled={batchApproveMutation.isPending}
className="px-2 py-1 bg-emerald-600 text-white text-xs rounded hover:bg-emerald-700 disabled:opacity-50"
>
Approve {selectedPending.length}
</button>
<button
type="button"
onClick={() => setShowBatchRejectInput((v) => !v)}
className="px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700"
>
Reject {selectedPending.length}
</button>
</>
)}
</div>
</div>
{/* Batch reject reason input */}
{showBatchRejectInput && selectedPending.length > 0 && (
<div className="mb-3 flex gap-2">
<input
type="text"
placeholder="Rejection reason (optional)…"
value={batchRejectReason}
onChange={(e) => setBatchRejectReason(e.target.value)}
className="flex-1 px-3 py-1.5 text-sm border border-amber-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-400"
/>
<button
type="button"
onClick={() =>
batchRejectMutation.mutate({
ids: selectedPending,
...(batchRejectReason.trim() ? { rejectionReason: batchRejectReason.trim() } : {}),
})
}
disabled={batchRejectMutation.isPending}
className="px-3 py-1.5 bg-red-600 text-white text-xs rounded hover:bg-red-700 disabled:opacity-50"
>
Confirm Reject
</button>
</div>
)}
<div className="space-y-2">
{pendingList.map((v) => (
<div
key={v.id}
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg px-4 py-2 shadow-sm"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<input
type="checkbox"
checked={selected.has(v.id)}
onChange={(e) => {
const next = new Set(selected);
if (e.target.checked) next.add(v.id);
else next.delete(v.id);
setSelected(next);
}}
className="rounded border-gray-300 dark:border-gray-600 text-amber-600 shrink-0"
/>
<span className="font-medium text-sm text-gray-900 dark:text-gray-100">{v.resource.displayName}</span>
<span className="text-xs text-gray-500 dark:text-gray-400">({v.resource.eid})</span>
<span className="mx-1 text-gray-300 dark:text-gray-600">·</span>
<span className="text-sm text-gray-600 dark:text-gray-400">{TYPE_LABELS[v.type as VacationType]}</span>
<span className="mx-1 text-gray-300 dark:text-gray-600">·</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(v.startDate).toLocaleDateString("en-GB")} {" "}
{new Date(v.endDate).toLocaleDateString("en-GB")}
</span>
{v.note && (
<span className="text-xs text-gray-400 ml-2 italic truncate">{v.note}</span>
)}
</div>
<div className="flex gap-2 ml-4 shrink-0">
<button
type="button"
onClick={() => approveMutation.mutate({ id: v.id })}
disabled={approveMutation.isPending}
className="px-3 py-1 bg-emerald-600 text-white text-xs rounded hover:bg-emerald-700 disabled:opacity-50"
>
Approve
</button>
<button
type="button"
onClick={() => rejectMutation.mutate({ id: v.id })}
disabled={rejectMutation.isPending}
className="px-3 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 disabled:opacity-50"
>
Reject
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as VacationStatusFilter)}
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
>
<option value="ALL">All statuses</option>
{Object.values(VacationStatus).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as VacationTypeFilter)}
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
>
<option value="ALL">All types</option>
{Object.values(VacationType).map((t) => (
<option key={t} value={t}>{TYPE_LABELS[t]}</option>
))}
</select>
<select
value={resourceFilter}
onChange={(e) => setResourceFilter(e.target.value)}
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
>
<option value="">All resources</option>
{resourceList.map((r) => (
<option key={r.id} value={r.id}>{r.displayName} ({r.eid})</option>
))}
</select>
</div>
{/* Filter chips */}
{chips.length > 0 && (
<FilterChips chips={chips} onClearAll={clearAll} />
)}
{/* List */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-sm text-gray-400">Loading</div>
) : vacationError ? (
<div className="p-8 text-center text-sm text-red-500">Error: {vacationError.message}</div>
) : vacationList.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-400">No vacations found.</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<SortableColumnHeader label="Resource" field="resource" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
<th className="px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
<span className="inline-flex items-center gap-0.5">
<button
type="button"
onClick={() => handleSort("type")}
className="flex items-center gap-0.5 justify-start w-full hover:text-gray-700 transition-colors group"
>
Type
</button>
<InfoTooltip content="ANNUAL = paid annual leave · SICK = sick leave · PUBLIC_HOLIDAY = public holiday · OTHER = other leave types." />
</span>
</th>
<SortableColumnHeader label="Start" field="startDate" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
<SortableColumnHeader label="End" field="endDate" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
<th className="px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
<span className="inline-flex items-center gap-0.5">
<button
type="button"
onClick={() => handleSort("status")}
className="flex items-center gap-0.5 justify-start w-full hover:text-gray-700 transition-colors group"
>
Status
</button>
<InfoTooltip content="PENDING = awaiting manager approval · APPROVED = confirmed leave · REJECTED = declined by manager · CANCELLED = withdrawn by employee." />
</span>
</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
Note / Reason <InfoTooltip content="Employee's leave note, or manager's rejection reason if status is REJECTED." />
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-50 dark:divide-gray-700">
{sorted.map((v) => {
const type = v.type as VacationType;
const status = v.status as VacationStatus;
const resource = v.resource as { displayName: string; eid: string } | undefined;
const vExtra = v as unknown as { rejectionReason?: string | null; isHalfDay?: boolean };
return (
<tr key={v.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3">
<span className="font-medium text-gray-900 dark:text-gray-100">
{resource?.displayName ?? "—"}
</span>
{resource?.eid && (
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">({resource.eid})</span>
)}
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{TYPE_LABELS[type] ?? type}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{new Date(v.startDate).toLocaleDateString("en-GB")}
{vExtra.isHalfDay && <span className="ml-1 text-xs text-gray-400 dark:text-gray-500">½</span>}
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{new Date(v.endDate).toLocaleDateString("en-GB")}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[status] ?? ""}`}
>
{status}
</span>
</td>
<td className="px-4 py-3 text-gray-400 dark:text-gray-500 text-xs max-w-xs truncate">
{vExtra.rejectionReason ? (
<span className="text-red-500">{vExtra.rejectionReason}</span>
) : (v.note ?? "—")}
</td>
<td className="px-4 py-3 text-right space-x-3">
{status === VacationStatus.CANCELLED ? (
<button
type="button"
onClick={() => approveMutation.mutate({ id: v.id })}
disabled={approveMutation.isPending}
className="text-xs text-emerald-600 dark:text-emerald-400 hover:text-emerald-800 underline disabled:opacity-50"
>
Re-approve
</button>
) : (
<button
type="button"
onClick={() => cancelMutation.mutate({ id: v.id })}
disabled={cancelMutation.isPending}
className="text-xs text-gray-400 hover:text-red-600 underline disabled:opacity-50"
>
Cancel
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</>
)}
{showModal && (
<VacationModal
onClose={() => setShowModal(false)}
onSuccess={() => {
setShowModal(false);
void refetch();
}}
/>
)}
</div>
);
}
@@ -0,0 +1,332 @@
"use client";
import { useRef, useState } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { VacationType } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { useDebounce } from "~/hooks/useDebounce.js";
const VACATION_TYPES = Object.values(VacationType);
const VACATION_TYPE_LABELS: Record<VacationType, string> = {
ANNUAL: "Annual Leave",
SICK: "Sick Leave",
PUBLIC_HOLIDAY: "Public Holiday",
OTHER: "Other",
};
interface VacationModalProps {
resourceId?: string;
onClose: () => void;
onSuccess: () => void;
}
function toDateInputValue(date: Date | string | null | undefined): string {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
return d.toISOString().split("T")[0] ?? "";
}
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
const [resourceId, setResourceId] = useState(initialResourceId ?? "");
const [type, setType] = useState<VacationType>(VacationType.ANNUAL);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [note, setNote] = useState("");
const [isHalfDay, setIsHalfDay] = useState(false);
const [halfDayPart, setHalfDayPart] = useState<"MORNING" | "AFTERNOON">("MORNING");
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const debouncedStart = useDebounce(startDate, 400);
const debouncedEnd = useDebounce(endDate, 400);
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
// Team overlap: other resources in same chapter off in this period
const { data: teamOverlap } = trpc.vacation.getTeamOverlap.useQuery(
{
resourceId: resourceId,
startDate: debouncedStart ? new Date(debouncedStart) : new Date(),
endDate: debouncedEnd ? new Date(debouncedEnd) : new Date(),
},
{
enabled: !!resourceId && !!debouncedStart && !!debouncedEnd,
staleTime: 10_000,
},
);
// Show existing vacations for this resource in the selected period
const { data: existingVacations } = trpc.vacation.list.useQuery(
{
resourceId: resourceId || undefined,
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
},
{ enabled: !!resourceId && !!startDate && !!endDate, staleTime: 10_000 },
);
const utils = trpc.useUtils();
const createMutation = trpc.vacation.create.useMutation({
onSuccess: async () => {
await utils.vacation.list.invalidate();
onSuccess();
},
onError: (err) => setServerError(err.message),
});
const isPending = createMutation.isPending;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setServerError(null);
if (!resourceId || !startDate || !endDate) {
setServerError("Please fill in all required fields.");
return;
}
const start = new Date(startDate);
const end = new Date(endDate);
if (end < start) {
setServerError("End date must be on or after start date.");
return;
}
createMutation.mutate({
resourceId,
type,
startDate: start,
endDate: end,
note: note || undefined,
isHalfDay,
...(isHalfDay ? { halfDayPart } : {}),
});
}
const inputClass =
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const resourceList = resources?.resources ?? [];
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Request Vacation</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-xl leading-none"
aria-label="Close"
>
&times;
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
{/* Resource */}
{!initialResourceId && (
<div>
<label htmlFor="vac-resource" className={labelClass}>
Resource <span className="text-red-500">*</span>
</label>
<select
id="vac-resource"
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
className={inputClass}
required
>
<option value="">Select a resource</option>
{resourceList.map((r) => (
<option key={r.id} value={r.id}>
{r.displayName} ({r.eid})
</option>
))}
</select>
</div>
)}
{/* Type */}
<div>
<label htmlFor="vac-type" className={labelClass}>
Type <span className="text-red-500">*</span>
</label>
<select
id="vac-type"
value={type}
onChange={(e) => setType(e.target.value as VacationType)}
className={inputClass}
>
{VACATION_TYPES.map((t) => (
<option key={t} value={t}>
{VACATION_TYPE_LABELS[t]}
</option>
))}
</select>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="vac-start" className={labelClass}>
Start Date <span className="text-red-500">*</span>
</label>
<DateInput
id="vac-start"
value={startDate}
onChange={setStartDate}
className={inputClass}
required
/>
</div>
<div>
<label htmlFor="vac-end" className={labelClass}>
End Date <span className="text-red-500">*</span>
</label>
<DateInput
id="vac-end"
value={endDate}
onChange={setEndDate}
min={startDate}
className={inputClass}
required
/>
</div>
</div>
{/* Half-day toggle */}
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isHalfDay}
onChange={(e) => setIsHalfDay(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Half day</span>
</label>
{isHalfDay && (
<div className="flex gap-3">
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-gray-600 dark:text-gray-400">
<input
type="radio"
value="MORNING"
checked={halfDayPart === "MORNING"}
onChange={() => setHalfDayPart("MORNING")}
className="text-brand-600 focus:ring-brand-500"
/>
Morning
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-gray-600 dark:text-gray-400">
<input
type="radio"
value="AFTERNOON"
checked={halfDayPart === "AFTERNOON"}
onChange={() => setHalfDayPart("AFTERNOON")}
className="text-brand-600 focus:ring-brand-500"
/>
Afternoon
</label>
</div>
)}
</div>
{/* Conflict warning */}
{existingVacations && existingVacations.length > 0 && (
<div className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
<strong>Existing vacation in this period:</strong>
<ul className="mt-1 space-y-0.5">
{existingVacations.map((v) => (
<li key={v.id}>
{VACATION_TYPE_LABELS[v.type as VacationType]} {" "}
{new Date(v.startDate).toLocaleDateString("en-GB")} to{" "}
{new Date(v.endDate).toLocaleDateString("en-GB")} ({v.status})
</li>
))}
</ul>
</div>
)}
{/* Team overlap warning */}
{teamOverlap && teamOverlap.length > 0 && (
<div className="rounded-lg bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-700">
<strong>Team members off in this period ({teamOverlap.length}):</strong>
<ul className="mt-1 space-y-0.5">
{teamOverlap.map((v) => {
const r = v.resource as { displayName: string; eid: string } | null;
return (
<li key={v.id}>
{r?.displayName ?? "—"}{" "}
<span className="text-blue-500">({new Date(v.startDate).toLocaleDateString("en-GB")} {new Date(v.endDate).toLocaleDateString("en-GB")})</span>
</li>
);
})}
</ul>
</div>
)}
{/* Note */}
<div>
<label htmlFor="vac-note" className={labelClass}>
Note (optional)
</label>
<textarea
id="vac-note"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
maxLength={500}
className={`${inputClass} resize-none`}
placeholder="Optional reason or description…"
/>
</div>
{/* Server error */}
{serverError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{serverError}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Submitting…" : "Submit Request"}
</button>
</div>
</form>
</div>
</div>
);
}