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>
);
}