chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -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">×</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">×</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">×</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"
|
||||
>
|
||||
×
|
||||
</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">×</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">×</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">×</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">×</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">×</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">×</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">×</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"}{" "}
|
||||
—{" "}
|
||||
{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">×</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">×</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 0–100 <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 (1–5) across all skills, scaled to 0–100. 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"
|
||||
>
|
||||
×
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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">×</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">×</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user