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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import type { AllocationWithDetails, RecurrencePattern } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
import { RecurrenceEditor } from "./RecurrenceEditor.js";
|
||||
|
||||
const ALLOCATION_STATUSES = Object.values(AllocationStatus);
|
||||
type EntryKind = "demand" | "assignment";
|
||||
|
||||
interface AllocationModalProps {
|
||||
allocation?: AllocationWithDetails | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function toDateInputValue(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toISOString().split("T")[0] ?? "";
|
||||
}
|
||||
|
||||
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
|
||||
const isEditing = Boolean(allocation);
|
||||
const initialEntryKind: EntryKind = allocation && !allocation.resourceId ? "demand" : "assignment";
|
||||
const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind);
|
||||
const isDemandEntry = entryKind === "demand";
|
||||
|
||||
const [resourceId, setResourceId] = useState(allocation?.resourceId ?? "");
|
||||
const [projectId, setProjectId] = useState(allocation?.projectId ?? "");
|
||||
const [roleId, setRoleId] = useState(allocation?.roleId ?? "");
|
||||
const [roleFreeText, setRoleFreeText] = useState(allocation?.role ?? "");
|
||||
const [headcount, setHeadcount] = useState(allocation?.headcount ?? 1);
|
||||
const [startDate, setStartDate] = useState(toDateInputValue(allocation?.startDate));
|
||||
const [endDate, setEndDate] = useState(toDateInputValue(allocation?.endDate));
|
||||
const [hoursPerDay, setHoursPerDay] = useState<number>(allocation?.hoursPerDay ?? 8);
|
||||
const [status, setStatus] = useState<AllocationStatus>(
|
||||
allocation?.status ?? AllocationStatus.PROPOSED,
|
||||
);
|
||||
const existingMeta = allocation?.metadata as Record<string, unknown> | undefined;
|
||||
const [isRecurring, setIsRecurring] = useState<boolean>(!!existingMeta?.recurrence);
|
||||
const [recurrence, setRecurrence] = useState<RecurrencePattern | undefined>(
|
||||
existingMeta?.recurrence as RecurrencePattern | undefined,
|
||||
);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const { data: projects } = trpc.project.list.useQuery(
|
||||
{ limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const { data: rolesData } = trpc.role.list.useQuery(
|
||||
{ isActive: true },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const invalidatePlanningViews = () => {
|
||||
void utils.allocation.list.invalidate();
|
||||
void (utils as { allocation: { listView: { invalidate: () => Promise<unknown> } } }).allocation.listView.invalidate();
|
||||
void utils.allocation.listDemands.invalidate();
|
||||
void utils.allocation.listAssignments.invalidate();
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createDemandMutation = (trpc.allocation.createDemandRequirement.useMutation as any)({
|
||||
onSuccess: () => {
|
||||
invalidatePlanningViews();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: { message: string }) => {
|
||||
setServerError(err.message);
|
||||
},
|
||||
}) as {
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
error?: { message: string };
|
||||
mutate: (input: unknown) => void;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createAssignmentMutation = (trpc.allocation.createAssignment.useMutation as any)({
|
||||
onSuccess: () => {
|
||||
invalidatePlanningViews();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: { message: string }) => {
|
||||
setServerError(err.message);
|
||||
},
|
||||
}) as {
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
error?: { message: string };
|
||||
mutate: (input: unknown) => void;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const updateMutation = (trpc.allocation.update.useMutation as any)({
|
||||
onSuccess: () => {
|
||||
invalidatePlanningViews();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: { message: string }) => {
|
||||
setServerError(err.message);
|
||||
},
|
||||
}) as {
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
error?: { message: string };
|
||||
mutate: (input: unknown) => void;
|
||||
};
|
||||
|
||||
const isPending =
|
||||
createDemandMutation.isPending ||
|
||||
createAssignmentMutation.isPending ||
|
||||
updateMutation.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
setServerError(null);
|
||||
}, [resourceId, projectId, roleId, roleFreeText, startDate, endDate, hoursPerDay, status, entryKind]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setServerError(null);
|
||||
|
||||
if (!projectId) {
|
||||
setServerError("Please select a project.");
|
||||
return;
|
||||
}
|
||||
if (!isDemandEntry && !resourceId) {
|
||||
setServerError("Please select a resource.");
|
||||
return;
|
||||
}
|
||||
if (!startDate || !endDate) {
|
||||
setServerError("Please fill in start and end dates.");
|
||||
return;
|
||||
}
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
if (end < start) {
|
||||
setServerError("End date must be on or after start date.");
|
||||
return;
|
||||
}
|
||||
|
||||
const baseMeta = (allocation?.metadata as Record<string, unknown> | undefined) ?? {};
|
||||
const metadata: Record<string, unknown> = {
|
||||
...baseMeta,
|
||||
...(isRecurring && recurrence ? { recurrence } : { recurrence: undefined }),
|
||||
};
|
||||
if (!isRecurring) delete metadata.recurrence;
|
||||
|
||||
// Determine role string from roleId if set
|
||||
const rolesList = rolesData ?? [];
|
||||
const selectedRole = rolesList.find((r) => r.id === roleId);
|
||||
const roleString = selectedRole ? selectedRole.name : (roleFreeText || undefined);
|
||||
|
||||
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
|
||||
|
||||
if (isEditing && allocation) {
|
||||
updateMutation.mutate({
|
||||
id: getPlanningEntryMutationId(allocation),
|
||||
data: {
|
||||
resourceId: isDemandEntry ? undefined : (resourceId || undefined),
|
||||
projectId,
|
||||
role: roleString,
|
||||
roleId: roleId || undefined,
|
||||
headcount: isDemandEntry ? headcount : 1,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
hoursPerDay,
|
||||
percentage,
|
||||
status: status as AllocationStatus,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
} else if (isDemandEntry) {
|
||||
createDemandMutation.mutate({
|
||||
projectId,
|
||||
role: roleString,
|
||||
roleId: roleId || undefined,
|
||||
headcount,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
hoursPerDay,
|
||||
percentage,
|
||||
status: status as AllocationStatus,
|
||||
metadata,
|
||||
});
|
||||
} else {
|
||||
createAssignmentMutation.mutate({
|
||||
resourceId,
|
||||
projectId,
|
||||
role: roleString,
|
||||
roleId: roleId || undefined,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
hoursPerDay,
|
||||
percentage,
|
||||
status: status as AllocationStatus,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
||||
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||
|
||||
const resourceList = (resources?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
|
||||
const projectList = (projects?.projects ?? []) as Array<{ id: string; name: string; shortCode: string }>;
|
||||
const rolesList = (rolesData ?? []) as Array<{ id: string; name: string; color: string | null }>;
|
||||
const entryLabel = isDemandEntry ? "Open Demand" : "Assignment";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-xl mx-4"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{isEditing ? `Edit ${entryLabel}` : `New ${entryLabel}`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors text-xl leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
||||
{/* Demand toggle */}
|
||||
<div className="flex items-center gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer select-none flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDemandEntry}
|
||||
disabled={isEditing}
|
||||
onChange={(e) => {
|
||||
setEntryKind(e.target.checked ? "demand" : "assignment");
|
||||
if (e.target.checked) setResourceId("");
|
||||
}}
|
||||
className="rounded border-gray-300 dark:border-gray-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium text-gray-800 dark:text-gray-100">Open demand</span>
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
||||
{isEditing
|
||||
? "Demand vs assignment type is fixed after creation during the compatibility migration."
|
||||
: "No resource assigned yet and tracked as staffing demand"}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
{isDemandEntry && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Headcount:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={headcount}
|
||||
onChange={(e) => setHeadcount(Math.max(1, Number(e.target.value)))}
|
||||
min={1}
|
||||
max={50}
|
||||
className="w-16 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm text-center dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resource is only required for assignments */}
|
||||
{!isDemandEntry && (
|
||||
<div>
|
||||
<label htmlFor="modal-resource" className={labelClass}>
|
||||
Resource <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="modal-resource"
|
||||
value={resourceId}
|
||||
onChange={(e) => setResourceId(e.target.value)}
|
||||
className={inputClass}
|
||||
required={!isDemandEntry}
|
||||
>
|
||||
<option value="">Select a resource…</option>
|
||||
{resourceList.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.displayName} ({r.eid})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project */}
|
||||
<div>
|
||||
<label htmlFor="modal-project" className={labelClass}>
|
||||
Project <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="modal-project"
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
className={inputClass}
|
||||
required
|
||||
>
|
||||
<option value="">Select a project…</option>
|
||||
{projectList.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.shortCode} — {p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label htmlFor="modal-role" className={labelClass}>Role</label>
|
||||
<select
|
||||
id="modal-role"
|
||||
value={roleId}
|
||||
onChange={(e) => setRoleId(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">No role / custom…</option>
|
||||
{rolesList.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!roleId && (
|
||||
<input
|
||||
type="text"
|
||||
value={roleFreeText}
|
||||
onChange={(e) => setRoleFreeText(e.target.value)}
|
||||
placeholder="Or type a custom role…"
|
||||
className={`${inputClass} mt-1`}
|
||||
maxLength={200}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="modal-start" className={labelClass}>
|
||||
Start Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-start"
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
className={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-end" className={labelClass}>
|
||||
End Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-end"
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate}
|
||||
className={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hours/Day + Status */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="modal-hours" className={labelClass}>
|
||||
Hours / Day
|
||||
</label>
|
||||
<input
|
||||
id="modal-hours"
|
||||
type="number"
|
||||
value={hoursPerDay}
|
||||
onChange={(e) => setHoursPerDay(Number(e.target.value))}
|
||||
min={0.5}
|
||||
max={8}
|
||||
step={0.5}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-status" className={labelClass}>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="modal-status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as AllocationStatus)}
|
||||
className={inputClass}
|
||||
>
|
||||
{ALLOCATION_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurring toggle */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRecurring}
|
||||
onChange={(e) => {
|
||||
setIsRecurring(e.target.checked);
|
||||
if (!e.target.checked) setRecurrence(undefined);
|
||||
}}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">Recurring schedule</span>
|
||||
</label>
|
||||
{isRecurring && (
|
||||
<div className="mt-2">
|
||||
<RecurrenceEditor value={recurrence} onChange={setRecurrence} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Server error */}
|
||||
{serverError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||
{serverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { formatDate } from "~/lib/format.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { AllocationModal } from "./AllocationModal.js";
|
||||
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, ColumnDef } from "@planarchy/shared";
|
||||
import { AllocationStatus, ALLOCATION_COLUMNS } from "@planarchy/shared";
|
||||
import { useSelection } from "~/hooks/useSelection.js";
|
||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
import { FilterBar } from "~/components/ui/FilterBar.js";
|
||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
|
||||
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
|
||||
import { ColumnTogglePanel } from "~/components/ui/ColumnTogglePanel.js";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
ACTIVE: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
PROPOSED: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
||||
CONFIRMED: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
COMPLETED: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400",
|
||||
CANCELLED: "bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400",
|
||||
};
|
||||
|
||||
const ALL_ALLOC_STATUSES = [
|
||||
{ value: "PROPOSED", label: "Proposed" },
|
||||
{ value: "CONFIRMED", label: "Confirmed" },
|
||||
{ value: "ACTIVE", label: "Active" },
|
||||
{ value: "COMPLETED", label: "Completed" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
] as const;
|
||||
|
||||
type AllocationAssignmentsView = AllocationReadModel<AllocationLike>;
|
||||
type DemandRow = AllocationWithDetails & {
|
||||
sourceAllocationId?: string;
|
||||
requestedHeadcount?: number;
|
||||
unfilledHeadcount?: number;
|
||||
};
|
||||
|
||||
export function AllocationsClient() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingAllocation, setEditingAllocation] = useState<AllocationWithDetails | null>(null);
|
||||
const [filterProjectId, setFilterProjectId] = useState<string>("");
|
||||
const [filterResourceId, setFilterResourceId] = useState<string>("");
|
||||
const [filterStatus, setFilterStatus] = useState<string>("");
|
||||
const [hidePastProjects, setHidePastProjects] = useState(true);
|
||||
const [hideCompletedProjects, setHideCompletedProjects] = useState(true);
|
||||
const [hideDraftProjects, setHideDraftProjects] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ single?: AllocationWithDetails; ids?: string[] } | null>(null);
|
||||
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
|
||||
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
|
||||
|
||||
const selection = useSelection();
|
||||
const utils = trpc.useUtils();
|
||||
const { canViewCosts } = usePermissions();
|
||||
|
||||
// ─── Column visibility ────────────────────────────────────────────────────
|
||||
const baseColumns = useMemo<ColumnDef[]>(
|
||||
() => (canViewCosts ? ALLOCATION_COLUMNS : ALLOCATION_COLUMNS.filter((c) => c.key !== "cost")),
|
||||
[canViewCosts],
|
||||
);
|
||||
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig("allocations", baseColumns);
|
||||
const defaultKeys = useMemo(() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key), [baseColumns]);
|
||||
|
||||
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
|
||||
{
|
||||
projectId: filterProjectId || undefined,
|
||||
resourceId: filterResourceId || undefined,
|
||||
status: (filterStatus as AllocationStatus) || undefined,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ placeholderData: (prev: any) => prev, staleTime: 15_000 },
|
||||
) as { data: AllocationAssignmentsView | undefined; isLoading: boolean };
|
||||
|
||||
const deleteDemandMutation = trpc.allocation.deleteDemandRequirement.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.allocation.list.invalidate();
|
||||
await utils.allocation.listView.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAssignmentMutation = trpc.allocation.deleteAssignment.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.allocation.list.invalidate();
|
||||
await utils.allocation.listView.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.allocation.list.invalidate();
|
||||
await utils.allocation.listView.invalidate();
|
||||
selection.clear();
|
||||
},
|
||||
});
|
||||
|
||||
const batchStatusMutation = trpc.allocation.batchUpdateStatus.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.allocation.list.invalidate();
|
||||
await utils.allocation.listView.invalidate();
|
||||
selection.clear();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
selection.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filterProjectId, filterResourceId, filterStatus, hidePastProjects, hideCompletedProjects, hideDraftProjects]);
|
||||
|
||||
function openCreate() {
|
||||
setEditingAllocation(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(alloc: AllocationWithDetails) {
|
||||
setEditingAllocation(alloc);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setModalOpen(false);
|
||||
setEditingAllocation(null);
|
||||
}
|
||||
|
||||
const assignmentList = (allocationView?.assignments ?? []) as unknown as AllocationWithDetails[];
|
||||
const demandList = (allocationView?.demands ?? []) as unknown as DemandRow[];
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const filteredAllocations = assignmentList.filter((alloc) => {
|
||||
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today) return false;
|
||||
if (hideCompletedProjects && alloc.project?.status && ["COMPLETED", "CANCELLED"].includes(alloc.project.status)) return false;
|
||||
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const filteredDemands = demandList.filter((alloc) => {
|
||||
if (filterResourceId) return false;
|
||||
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today) return false;
|
||||
if (hideCompletedProjects && alloc.project?.status && ["COMPLETED", "CANCELLED"].includes(alloc.project.status)) return false;
|
||||
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const allocViewPrefs = useViewPrefs("allocations");
|
||||
const { sorted, sortField, sortDir, toggle } = useTableSort(filteredAllocations, {
|
||||
initialField: allocViewPrefs.savedSort?.field ?? null,
|
||||
initialDir: allocViewPrefs.savedSort?.dir ?? null,
|
||||
onSortChange: (field, dir) => {
|
||||
allocViewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
||||
},
|
||||
});
|
||||
const allocationIds = sorted.map((a) => a.id);
|
||||
const allocationMutationIdsByDisplayId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
sorted.map((allocation) => [allocation.id, getPlanningEntryMutationId(allocation)]),
|
||||
),
|
||||
[sorted],
|
||||
);
|
||||
const selectedMutationIds = useMemo(
|
||||
() =>
|
||||
selection.selectedArray.flatMap((displayId) => {
|
||||
const mutationId = allocationMutationIdsByDisplayId.get(displayId);
|
||||
return mutationId ? [mutationId] : [];
|
||||
}),
|
||||
[allocationMutationIdsByDisplayId, selection.selectedArray],
|
||||
);
|
||||
|
||||
function handleSort(field: string) {
|
||||
if (field === "resource") {
|
||||
toggle("resource", (a) => a.resource?.displayName ?? null);
|
||||
} else if (field === "project") {
|
||||
toggle("project", (a) => a.project?.name ?? null);
|
||||
} else {
|
||||
toggle(field);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setFilterProjectId("");
|
||||
setFilterResourceId("");
|
||||
setFilterStatus("");
|
||||
setHidePastProjects(false);
|
||||
setHideCompletedProjects(false);
|
||||
setHideDraftProjects(false);
|
||||
}
|
||||
|
||||
const chips = [
|
||||
...(filterProjectId ? [{ label: `Project filter active`, onRemove: () => setFilterProjectId("") }] : []),
|
||||
...(filterResourceId ? [{ label: `Resource filter active`, onRemove: () => setFilterResourceId("") }] : []),
|
||||
...(filterStatus ? [{ label: `Status: ${filterStatus}`, onRemove: () => setFilterStatus("") }] : []),
|
||||
...(hidePastProjects ? [{ label: "Hiding past projects", onRemove: () => setHidePastProjects(false) }] : []),
|
||||
...(hideCompletedProjects ? [{ label: "Hiding completed/cancelled", onRemove: () => setHideCompletedProjects(false) }] : []),
|
||||
...(hideDraftProjects ? [{ label: "Hiding draft projects", onRemove: () => setHideDraftProjects(false) }] : []),
|
||||
];
|
||||
|
||||
function formatPeriod(alloc: AllocationWithDetails) {
|
||||
return formatDate(alloc.startDate) + " \u2192 " + formatDate(alloc.endDate);
|
||||
}
|
||||
|
||||
function handleSingleDelete(allocation: AllocationWithDetails) {
|
||||
const id = getPlanningEntryMutationId(allocation);
|
||||
|
||||
if (!allocation.resourceId) {
|
||||
deleteDemandMutation.mutate({ id });
|
||||
return;
|
||||
}
|
||||
|
||||
deleteAssignmentMutation.mutate({ id });
|
||||
}
|
||||
|
||||
const singleDeletePending = deleteDemandMutation.isPending || deleteAssignmentMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24">
|
||||
{/* Page header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Allocations</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||
{isLoading
|
||||
? "Loading…"
|
||||
: `${filteredAllocations.length} assignment${filteredAllocations.length !== 1 ? "s" : ""}${filteredDemands.length > 0 ? ` · ${filteredDemands.length} open demand${filteredDemands.length !== 1 ? "s" : ""}` : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="/api/reports/allocations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
↓ PDF
|
||||
</a>
|
||||
<a
|
||||
href="/api/reports/allocations?format=xlsx"
|
||||
download
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
↓ XLS
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
New Planning Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar>
|
||||
<ProjectCombobox
|
||||
value={filterProjectId || null}
|
||||
onChange={(id) => setFilterProjectId(id ?? "")}
|
||||
placeholder="Filter by project…"
|
||||
className="min-w-[280px]"
|
||||
/>
|
||||
|
||||
<ResourceCombobox
|
||||
value={filterResourceId || null}
|
||||
onChange={(id) => setFilterResourceId(id ?? "")}
|
||||
placeholder="Filter by resource…"
|
||||
className="min-w-[180px]"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{ALL_ALLOC_STATUSES.map((s) => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hidePastProjects}
|
||||
onChange={(e) => setHidePastProjects(e.target.checked)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Hide past
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideCompletedProjects}
|
||||
onChange={(e) => setHideCompletedProjects(e.target.checked)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Hide completed
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideDraftProjects}
|
||||
onChange={(e) => setHideDraftProjects(e.target.checked)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Hide drafts
|
||||
</label>
|
||||
<ColumnTogglePanel
|
||||
allColumns={allColumns}
|
||||
visibleKeys={visibleKeys}
|
||||
onSetVisible={setVisible}
|
||||
defaultKeys={defaultKeys}
|
||||
/>
|
||||
</FilterBar>
|
||||
|
||||
{/* Filter chips */}
|
||||
{chips.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<FilterChips chips={chips} onClearAll={clearAll} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.isAllSelected(allocationIds)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = selection.isIndeterminate(allocationIds);
|
||||
}}
|
||||
onChange={() => selection.toggleAll(allocationIds)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
</th>
|
||||
{visibleColumns.map((col) => {
|
||||
const tooltips: Record<string, { tip: string; width?: string }> = {
|
||||
role: { tip: "The role this allocation was created for. May differ from the resource's primary role." },
|
||||
hoursPerDay: { tip: "Planned working hours per calendar day for this allocation." },
|
||||
cost: { tip: "Resource LCR × hours per day. Reflects the cost of one day of work for this allocation." },
|
||||
status: { tip: "PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed.", width: "w-72" },
|
||||
};
|
||||
const t = tooltips[col.key];
|
||||
const fieldMap: Record<string, string> = { dates: "startDate", hoursPerDay: "hoursPerDay", cost: "dailyCostCents" };
|
||||
return (
|
||||
<SortableColumnHeader
|
||||
key={col.key}
|
||||
label={col.label}
|
||||
field={fieldMap[col.key] ?? col.key}
|
||||
sortField={sortField}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
{...(t?.tip ? { tooltip: t.tip } : {})}
|
||||
{...(t?.width ? { tooltipWidth: t.width } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={9} className="text-center py-12 text-gray-400 dark:text-gray-500 text-sm">Loading allocations…</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!isLoading && sorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No assignments found.</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
sorted.map((alloc) => {
|
||||
const isSelected = selection.selectedIds.has(alloc.id);
|
||||
return (
|
||||
<tr key={alloc.id} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => selection.toggle(alloc.id)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
</td>
|
||||
{visibleColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "resource":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">{alloc.resource?.displayName ?? "—"}</td>;
|
||||
case "project":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{alloc.project ? (
|
||||
<><span className="font-mono text-xs">{alloc.project.shortCode}</span> {alloc.project.name}</>
|
||||
) : "—"}
|
||||
</td>
|
||||
);
|
||||
case "role":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{alloc.role}</td>;
|
||||
case "dates":
|
||||
return <td key={col.key} className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatPeriod(alloc)}</td>;
|
||||
case "hoursPerDay":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alloc.hoursPerDay}h</td>;
|
||||
case "cost":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{(alloc.dailyCostCents / 100).toFixed(0)} €</td>;
|
||||
case "status":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[alloc.status] ?? "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"}`}>
|
||||
{alloc.status}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
default:
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500">—</td>;
|
||||
}
|
||||
})}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<button type="button" onClick={() => openEdit(alloc)} className="text-xs text-blue-600 hover:text-blue-800 font-medium hover:underline">Edit</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete({ single: alloc })}
|
||||
disabled={singleDeletePending}
|
||||
className="text-xs text-red-500 hover:text-red-700 font-medium hover:underline disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!isLoading && filteredDemands.length > 0 && (
|
||||
<div className="mt-6 bg-white dark:bg-gray-800 rounded-xl border border-amber-200 dark:border-amber-800/60 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-amber-200 dark:border-amber-800/60 bg-amber-50/70 dark:bg-amber-950/20 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-200">Open Demands</h2>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300/80">
|
||||
Placeholder demand rows not yet assigned to a resource.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||
{filteredDemands.length} item{filteredDemands.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-amber-100 dark:divide-amber-900/40">
|
||||
{filteredDemands.map((demand) => (
|
||||
<div
|
||||
key={demand.id}
|
||||
className="px-4 py-3 flex items-center justify-between gap-4 hover:bg-amber-50/40 dark:hover:bg-amber-950/10"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{demand.project ? (
|
||||
<><span className="font-mono text-xs">{demand.project.shortCode}</span> {demand.project.name}</>
|
||||
) : "Unknown project"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{(demand.role ?? "Placeholder role")} · {formatPeriod(demand)} · {demand.hoursPerDay}h/day
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 flex-shrink-0">
|
||||
<div className="text-right">
|
||||
<div className="text-xs uppercase tracking-wide text-amber-700 dark:text-amber-300">Unfilled</div>
|
||||
<div className="text-sm font-semibold text-amber-900 dark:text-amber-200">
|
||||
{demand.unfilledHeadcount ?? demand.headcount} / {demand.requestedHeadcount ?? demand.headcount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(demand as AllocationWithDetails)}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 font-medium hover:underline"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete({ single: demand as AllocationWithDetails })}
|
||||
disabled={singleDeletePending}
|
||||
className="text-xs text-red-500 hover:text-red-700 font-medium hover:underline disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Status Picker */}
|
||||
{batchStatusPicker && (
|
||||
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl p-5 min-w-[220px]" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Set status for {selection.count} allocations</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{ALL_ALLOC_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirmBatchStatus({ ids: selectedMutationIds, status: s.value });
|
||||
setBatchStatusPicker(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[s.value]}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm single delete */}
|
||||
{confirmDelete?.single && (
|
||||
<ConfirmDialog
|
||||
title="Delete Allocation"
|
||||
message={`Delete allocation for ${confirmDelete.single.resource?.displayName ?? "resource"} on ${confirmDelete.single.project?.name ?? "project"}?`}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
handleSingleDelete(confirmDelete.single!);
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm batch delete */}
|
||||
{confirmDelete?.ids && (
|
||||
<ConfirmDialog
|
||||
title="Delete Allocations"
|
||||
message={`Delete ${confirmDelete.ids.length} selected allocation${confirmDelete.ids.length !== 1 ? "s" : ""}? This cannot be undone.`}
|
||||
confirmLabel="Delete All"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
batchDeleteMutation.mutate({ ids: confirmDelete.ids! });
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm batch status */}
|
||||
{confirmBatchStatus && (
|
||||
<ConfirmDialog
|
||||
title="Update Allocation Status"
|
||||
message={`Set ${confirmBatchStatus.ids.length} allocation${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`}
|
||||
confirmLabel="Update"
|
||||
onConfirm={() => {
|
||||
batchStatusMutation.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never });
|
||||
setConfirmBatchStatus(null);
|
||||
}}
|
||||
onCancel={() => setConfirmBatchStatus(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Batch Action Bar */}
|
||||
<BatchActionBar
|
||||
count={selection.count}
|
||||
onClear={selection.clear}
|
||||
actions={[
|
||||
{
|
||||
label: "Set Status…",
|
||||
onClick: () => setBatchStatusPicker(true),
|
||||
disabled: batchStatusMutation.isPending,
|
||||
},
|
||||
{
|
||||
label: `Delete (${selection.count})`,
|
||||
variant: "danger",
|
||||
onClick: () => setConfirmDelete({ ids: selectedMutationIds }),
|
||||
disabled: batchDeleteMutation.isPending,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
{modalOpen && (
|
||||
<AllocationModal allocation={editingAllocation} onClose={closeModal} onSuccess={closeModal} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface OpenDemandAllocation {
|
||||
id: string;
|
||||
entityId?: string | null;
|
||||
sourceAllocationId?: string | null;
|
||||
projectId: string;
|
||||
roleId: string | null;
|
||||
role: string | null;
|
||||
headcount: number;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
roleEntity?: { id: string; name: string; color: string | null } | null;
|
||||
project?: { id: string; name: string; shortCode: string };
|
||||
}
|
||||
|
||||
interface FillOpenDemandModalProps {
|
||||
allocation: OpenDemandAllocation;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toLocaleDateString("en-GB", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpenDemandModalProps) {
|
||||
const [resourceId, setResourceId] = useState("");
|
||||
const [hoursPerDay, setHoursPerDay] = useState<number>(allocation.hoursPerDay);
|
||||
const [search, setSearch] = useState("");
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const invalidatePlanningViews = async () => {
|
||||
await utils.allocation.list.invalidate();
|
||||
await utils.allocation.listView.invalidate();
|
||||
await utils.timeline.getEntries.invalidate();
|
||||
await utils.timeline.getEntriesView.invalidate();
|
||||
await utils.timeline.getProjectContext.invalidate();
|
||||
await utils.timeline.getBudgetStatus.invalidate();
|
||||
};
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
{ isActive: true, search: search || undefined, limit: 100 },
|
||||
{ staleTime: 15_000 },
|
||||
) as { data: { resources: Array<{ id: string; displayName: string; eid: string }> } | undefined };
|
||||
|
||||
const fillOpenDemandMutation = trpc.allocation.fillOpenDemandByAllocation.useMutation({
|
||||
onSuccess: async () => {
|
||||
await invalidatePlanningViews();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err) => setServerError(err.message),
|
||||
});
|
||||
|
||||
const roleName = allocation.roleEntity?.name ?? allocation.role ?? "Unknown Role";
|
||||
const roleColor = allocation.roleEntity?.color ?? "#6366f1";
|
||||
|
||||
const resourceList = resources?.resources ?? [];
|
||||
const isPending = fillOpenDemandMutation.isPending;
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!resourceId) {
|
||||
setServerError("Please select a resource.");
|
||||
return;
|
||||
}
|
||||
fillOpenDemandMutation.mutate({
|
||||
allocationId: getPlanningEntryMutationId(allocation),
|
||||
resourceId,
|
||||
hoursPerDay,
|
||||
status: AllocationStatus.PROPOSED,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-md mx-4"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Assign Open Demand</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pt-4 pb-2">
|
||||
{/* Demand summary */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 mb-4 flex items-start gap-3">
|
||||
<div className="w-3 h-3 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: roleColor }} />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{allocation.project?.name} · {formatDate(allocation.startDate)} – {formatDate(allocation.endDate)}
|
||||
</div>
|
||||
{allocation.headcount > 1 && (
|
||||
<div className="text-xs text-amber-600 mt-0.5">
|
||||
{allocation.headcount} slots remaining — assigning one resource
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 pb-5 space-y-4">
|
||||
{/* Resource search */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Search Resource
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or EID…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Assign Resource <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={resourceId}
|
||||
onChange={(e) => setResourceId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100"
|
||||
required
|
||||
size={Math.min(6, Math.max(3, resourceList.length))}
|
||||
>
|
||||
<option value="">Select a resource…</option>
|
||||
{resourceList.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.displayName} ({r.eid})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hours / Day</label>
|
||||
<input
|
||||
type="number"
|
||||
value={hoursPerDay}
|
||||
onChange={(e) => setHoursPerDay(Number(e.target.value))}
|
||||
min={0.5}
|
||||
max={8}
|
||||
step={0.5}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{serverError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||
{serverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} disabled={isPending} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={isPending || !resourceId} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50">
|
||||
{isPending ? "Assigning…" : "Create Assignment"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { RecurrenceFrequency } from "@planarchy/shared";
|
||||
import type { RecurrencePattern } from "@planarchy/shared";
|
||||
|
||||
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
interface RecurrenceEditorProps {
|
||||
value: RecurrencePattern | undefined;
|
||||
onChange: (pattern: RecurrencePattern | undefined) => void;
|
||||
}
|
||||
|
||||
export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
||||
const freq = value?.frequency ?? RecurrenceFrequency.WEEKLY;
|
||||
|
||||
function update(patch: Partial<RecurrencePattern>) {
|
||||
onChange({ ...value, frequency: freq, ...patch });
|
||||
}
|
||||
|
||||
function setFrequency(f: RecurrenceFrequency) {
|
||||
// Reset pattern-specific fields when switching frequency
|
||||
onChange({ frequency: f });
|
||||
}
|
||||
|
||||
function toggleWeekday(dow: number) {
|
||||
const current = value?.weekdays ?? [];
|
||||
const next = current.includes(dow)
|
||||
? current.filter((d) => d !== dow)
|
||||
: [...current, dow].sort((a, b) => a - b);
|
||||
update({ weekdays: next });
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100";
|
||||
const labelClass = "text-xs font-medium text-gray-600 dark:text-gray-400 block mb-1";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
{/* Frequency selector */}
|
||||
<div>
|
||||
<span className={labelClass}>Frequency</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{Object.values(RecurrenceFrequency).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFrequency(f)}
|
||||
className={`px-3 py-1 text-xs rounded-full border transition-colors ${
|
||||
freq === f
|
||||
? "bg-brand-600 text-white border-brand-600"
|
||||
: "border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-brand-400"
|
||||
}`}
|
||||
>
|
||||
{f === RecurrenceFrequency.WEEKLY
|
||||
? "Weekly"
|
||||
: f === RecurrenceFrequency.BIWEEKLY
|
||||
? "Biweekly"
|
||||
: f === RecurrenceFrequency.MONTHLY
|
||||
? "Monthly"
|
||||
: "Custom"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekday picker — WEEKLY and BIWEEKLY */}
|
||||
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
|
||||
<div>
|
||||
<span className={labelClass}>Days of week</span>
|
||||
<div className="flex gap-1">
|
||||
{WEEKDAY_LABELS.map((label, dow) => {
|
||||
const selected = (value?.weekdays ?? []).includes(dow);
|
||||
return (
|
||||
<button
|
||||
key={dow}
|
||||
type="button"
|
||||
onClick={() => toggleWeekday(dow)}
|
||||
className={`w-9 h-9 text-xs rounded-full border font-medium transition-colors ${
|
||||
selected
|
||||
? "bg-brand-600 text-white border-brand-600"
|
||||
: "border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-brand-400"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Biweekly interval */}
|
||||
{freq === RecurrenceFrequency.BIWEEKLY && (
|
||||
<div>
|
||||
<label className={labelClass}>Every N weeks</label>
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={8}
|
||||
value={value?.interval ?? 2}
|
||||
onChange={(e) => update({ interval: Number(e.target.value) })}
|
||||
className={`${inputClass} w-24`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly — day of month */}
|
||||
{freq === RecurrenceFrequency.MONTHLY && (
|
||||
<div>
|
||||
<label className={labelClass}>Day of month (1–31)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={value?.monthDay ?? 1}
|
||||
onChange={(e) => update({ monthDay: Number(e.target.value) })}
|
||||
className={`${inputClass} w-24`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom — hoursPerDay override */}
|
||||
{freq === RecurrenceFrequency.CUSTOM && (
|
||||
<div>
|
||||
<label className={labelClass}>Hours per active day</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={value?.hoursPerDay ?? 8}
|
||||
onChange={(e) => update({ hoursPerDay: Number(e.target.value) })}
|
||||
className={`${inputClass} w-24`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
|
||||
{freq !== RecurrenceFrequency.CUSTOM && (
|
||||
<div>
|
||||
<label className={labelClass}>Hours per recurring day (optional override)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={24}
|
||||
step={0.5}
|
||||
placeholder="Use allocation default"
|
||||
value={value?.hoursPerDay ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = { ...value, frequency: freq } as RecurrencePattern;
|
||||
if (e.target.value === "") {
|
||||
delete next.hoursPerDay;
|
||||
} else {
|
||||
next.hoursPerDay = Number(e.target.value);
|
||||
}
|
||||
onChange(next);
|
||||
}}
|
||||
className={`${inputClass} w-40`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional date range overrides */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Recurrence start (optional)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={value?.startDate ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = { ...value, frequency: freq } as RecurrencePattern;
|
||||
if (e.target.value) {
|
||||
next.startDate = e.target.value;
|
||||
} else {
|
||||
delete next.startDate;
|
||||
}
|
||||
onChange(next);
|
||||
}}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Recurrence end (optional)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={value?.endDate ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = { ...value, frequency: freq } as RecurrencePattern;
|
||||
if (e.target.value) {
|
||||
next.endDate = e.target.value;
|
||||
} else {
|
||||
delete next.endDate;
|
||||
}
|
||||
onChange(next);
|
||||
}}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useId } from "react";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
|
||||
|
||||
// SVG fill colors for the bar chart (work in both light and dark contexts)
|
||||
const PROFICIENCY_SVG_COLORS = ["#9ca3af", "#60a5fa", "#818cf8", "#f59e0b", "#4ade80"];
|
||||
|
||||
// Tailwind class sets per proficiency level (1–5), dark-mode aware
|
||||
const PROFICIENCY_CLASSES = [
|
||||
"bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500",
|
||||
"bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/60 dark:text-blue-200 dark:border-blue-600",
|
||||
"bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-900/60 dark:text-indigo-200 dark:border-indigo-500",
|
||||
"bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/60 dark:text-amber-200 dark:border-amber-500",
|
||||
"bg-green-100 text-green-800 border-green-300 dark:bg-green-900/60 dark:text-green-200 dark:border-green-500",
|
||||
];
|
||||
|
||||
function proficiencyClasses(level: number): string {
|
||||
const idx = Math.max(0, Math.min(4, Math.round(level) - 1));
|
||||
return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!;
|
||||
}
|
||||
|
||||
function ProficiencyBadge({ value }: { value: number }) {
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded font-medium border ${proficiencyClasses(value)}`}>
|
||||
{value} {PROFICIENCY_LABELS[value] ?? ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type SkillRule = { skill: string; minProficiency: number };
|
||||
|
||||
export function SkillsAnalytics() {
|
||||
const datalistId = useId();
|
||||
|
||||
// ── Skill table filters ──────────────────────────────────────────────────
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("");
|
||||
const [minCount, setMinCount] = useState<number>(1);
|
||||
const [skillSearch, setSkillSearch] = useState<string>("");
|
||||
|
||||
// ── People Finder ────────────────────────────────────────────────────────
|
||||
const [rules, setRules] = useState<SkillRule[]>([]);
|
||||
const [operator, setOperator] = useState<"AND" | "OR">("AND");
|
||||
const [peopleChapter, setPeopleChapter] = useState<string>("");
|
||||
|
||||
const { data, isLoading, error } = trpc.resource.getSkillsAnalytics.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const activeRules = rules.filter((r) => r.skill.trim().length > 0);
|
||||
const { data: peopleResults, isFetching: peopleFetching } =
|
||||
trpc.resource.searchBySkills.useQuery(
|
||||
{
|
||||
rules: activeRules,
|
||||
operator,
|
||||
...(peopleChapter ? { chapter: peopleChapter } : {}),
|
||||
},
|
||||
{
|
||||
enabled: activeRules.length > 0,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
function addRule() {
|
||||
setRules((prev) => [...prev, { skill: "", minProficiency: 1 }]);
|
||||
}
|
||||
|
||||
function removeRule(idx: number) {
|
||||
setRules((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function updateRule(idx: number, patch: Partial<SkillRule>) {
|
||||
setRules((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
}
|
||||
|
||||
function exportXlsx() {
|
||||
if (!data) return;
|
||||
const rows = data.aggregated.map((e) => ({
|
||||
Skill: e.skill,
|
||||
Category: e.category,
|
||||
"# Resources": e.count,
|
||||
"Avg Proficiency": e.avgProficiency,
|
||||
Chapters: e.chapters.join(", "),
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Skills");
|
||||
XLSX.writeFile(wb, `skills-analytics-${Date.now()}.xlsx`);
|
||||
}
|
||||
|
||||
const allSkillNames = (data?.aggregated ?? []).map((e) => e.skill);
|
||||
|
||||
const filtered = (data?.aggregated ?? []).filter((e) => {
|
||||
if (categoryFilter && e.category !== categoryFilter) return false;
|
||||
if (e.count < minCount) return false;
|
||||
if (skillSearch && !e.skill.toLowerCase().includes(skillSearch.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const { sorted: sortedSkills, sortField: skillSortField, sortDir: skillSortDir, toggle: skillToggle } = useTableSort(filtered);
|
||||
const top20 = filtered.slice(0, 20);
|
||||
const gapSkills = (data?.aggregated ?? []).filter((e) => e.count < 3 && e.avgProficiency >= 3);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-64" />
|
||||
<div className="h-64 bg-gray-100 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Skills Analytics</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{data?.totalResources} active resources · {data?.totalSkillEntries} distinct skills
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={exportXlsx}
|
||||
disabled={!data}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
↓ Export XLS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── People Finder ──────────────────────────────────────────────────── */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800">People Finder</h2>
|
||||
<span className="text-xs text-gray-400">
|
||||
Find resources that match skill criteria
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<datalist id={datalistId}>
|
||||
{allSkillNames.map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 flex-wrap">
|
||||
{/* AND / OR connector label */}
|
||||
{idx > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOperator((op) => (op === "AND" ? "OR" : "AND"))}
|
||||
className="w-12 text-center text-xs font-bold px-2 py-1.5 rounded-lg border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 transition-colors shrink-0"
|
||||
>
|
||||
{operator}
|
||||
</button>
|
||||
)}
|
||||
{idx === 0 && (
|
||||
<span className="w-12 text-center text-xs font-medium text-gray-400 shrink-0">
|
||||
knows
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Skill input */}
|
||||
<input
|
||||
type="text"
|
||||
list={datalistId}
|
||||
placeholder="Skill name…"
|
||||
value={rule.skill}
|
||||
onChange={(e) => updateRule(idx, { skill: e.target.value })}
|
||||
className="flex-1 min-w-40 px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
|
||||
{/* Min proficiency selector */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-gray-400 shrink-0">min.</span>
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-200">
|
||||
{[1, 2, 3, 4, 5].map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
type="button"
|
||||
title={PROFICIENCY_LABELS[lvl]}
|
||||
onClick={() => updateRule(idx, { minProficiency: lvl })}
|
||||
className={`px-2 py-1 text-xs font-medium transition-colors ${
|
||||
rule.minProficiency === lvl
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-white text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRule(idx)}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors p-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add rule + chapter filter row */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-dashed border-brand-300 text-brand-600 hover:bg-brand-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add skill rule
|
||||
</button>
|
||||
|
||||
{rules.length > 1 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500">Match:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOperator("AND")}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-l-lg border transition-colors ${
|
||||
operator === "AND"
|
||||
? "bg-brand-600 border-brand-600 text-white"
|
||||
: "bg-white border-gray-200 text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
All (AND)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOperator("OR")}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-r-lg border -ml-px transition-colors ${
|
||||
operator === "OR"
|
||||
? "bg-brand-600 border-brand-600 text-white"
|
||||
: "bg-white border-gray-200 text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Any (OR)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(data?.allChapters ?? []).length > 0 && (
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<span className="text-xs text-gray-500">Chapter:</span>
|
||||
<select
|
||||
value={peopleChapter}
|
||||
onChange={(e) => setPeopleChapter(e.target.value)}
|
||||
className="px-2 py-1.5 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All chapters</option>
|
||||
{(data?.allChapters ?? []).map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{activeRules.length > 0 && (
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
{peopleFetching ? (
|
||||
<div className="text-sm text-gray-400 animate-pulse">Searching…</div>
|
||||
) : peopleResults && peopleResults.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic">No resources match these criteria.</p>
|
||||
) : peopleResults && peopleResults.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{peopleResults.length} resource{peopleResults.length !== 1 ? "s" : ""} found
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{peopleResults.map((person) => (
|
||||
<div
|
||||
key={person.id}
|
||||
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/resources/${person.id}`}
|
||||
className="text-sm font-medium text-gray-900 hover:text-brand-600 transition-colors"
|
||||
>
|
||||
{person.displayName}
|
||||
</a>
|
||||
{person.chapter && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-100">
|
||||
{person.chapter}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{person.matchedSkills.map((s) => (
|
||||
<span
|
||||
key={s.skill}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border ${proficiencyClasses(s.proficiency)}`}
|
||||
>
|
||||
{s.skill}
|
||||
<span className="font-semibold">{s.proficiency}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`/resources/${person.id}`}
|
||||
className="text-xs text-brand-600 hover:underline shrink-0 mt-0.5"
|
||||
>
|
||||
View →
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Skill table filters ────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{/* Fuzzy search */}
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search skills…"
|
||||
value={skillSearch}
|
||||
onChange={(e) => setSkillSearch(e.target.value)}
|
||||
className="pl-8 pr-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500 w-52"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{(data?.categories ?? []).map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600">
|
||||
Min. resources:
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={minCount}
|
||||
onChange={(e) => setMinCount(Math.max(1, parseInt(e.target.value, 10) || 1))}
|
||||
className="w-16 px-2 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<span className="text-sm text-gray-400">{filtered.length} skills shown</span>
|
||||
|
||||
{(skillSearch || categoryFilter || minCount > 1) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSkillSearch(""); setCategoryFilter(""); setMinCount(1); }}
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top 20 Skills Bar Chart */}
|
||||
{top20.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-4">Top Skills by Resource Count</h2>
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<BarChart data={top20} layout="vertical" margin={{ left: 160, right: 20, top: 0, bottom: 0 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="skill" tick={{ fontSize: 11 }} width={155} />
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined) => [`${value ?? 0} resources`, "Count"] as [string, string]}
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8 }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
|
||||
{top20.map((entry) => (
|
||||
<Cell key={entry.skill} fill={PROFICIENCY_SVG_COLORS[Math.max(0, Math.min(4, Math.round(entry.avgProficiency) - 1))] ?? "#6b7280"} strokeWidth={0} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="text-xs text-gray-400 mt-2">Bar color = average proficiency (light → dark = low → high)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills Gap */}
|
||||
{gapSkills.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-3">
|
||||
Skills Gap
|
||||
<span className="ml-2 text-xs font-normal text-gray-400">high proficiency, few practitioners (<3)</span>
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{gapSkills.map((e) => (
|
||||
<button
|
||||
key={e.skill}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRules((prev) => [...prev, { skill: e.skill, minProficiency: 3 }]);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
title="Add to People Finder"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm rounded-full bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
{e.skill}
|
||||
<span className="text-xs opacity-70">{e.count} person{e.count !== 1 ? "s" : ""}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">Click a skill to add it to the People Finder above.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills Table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<SortableColumnHeader label="Skill" field="skill" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} />
|
||||
<SortableColumnHeader label="Category" field="category" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} />
|
||||
<SortableColumnHeader label="Resources" field="count" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} align="right" />
|
||||
<SortableColumnHeader label="Avg Prof." field="avgProficiency" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} align="right" />
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chapters</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Find</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sortedSkills.map((e) => (
|
||||
<tr key={e.skill} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900">{e.skill}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500">{e.category}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700">{e.count}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<ProficiencyBadge value={e.avgProficiency} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 text-xs">{e.chapters.join(", ") || "—"}</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<button
|
||||
type="button"
|
||||
title="Add to People Finder"
|
||||
onClick={() => {
|
||||
setRules((prev) => [...prev, { skill: e.skill, minProficiency: 1 }]);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
className="text-gray-400 hover:text-brand-600 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sortedSkills.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-10 text-gray-400 text-sm">
|
||||
No skills found matching the filters.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FieldType } from "@planarchy/shared";
|
||||
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { RolePresetsEditor } from "./RolePresetsEditor.js";
|
||||
|
||||
const FIELD_TYPES: { value: FieldType; label: string }[] = [
|
||||
{ value: FieldType.TEXT, label: "Text" },
|
||||
{ value: FieldType.TEXTAREA, label: "Textarea" },
|
||||
{ value: FieldType.NUMBER, label: "Number" },
|
||||
{ value: FieldType.BOOLEAN, label: "Boolean" },
|
||||
{ value: FieldType.DATE, label: "Date" },
|
||||
{ value: FieldType.SELECT, label: "Select" },
|
||||
{ value: FieldType.MULTI_SELECT, label: "Multi-Select" },
|
||||
{ value: FieldType.URL, label: "URL" },
|
||||
{ value: FieldType.EMAIL, label: "Email" },
|
||||
];
|
||||
|
||||
const INPUT_CLS =
|
||||
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
|
||||
|
||||
const BTN_PRIMARY =
|
||||
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
|
||||
|
||||
const BTN_SECONDARY =
|
||||
"px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium";
|
||||
|
||||
const BTN_DANGER =
|
||||
"px-2 py-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded text-sm transition-colors";
|
||||
|
||||
function makeEmptyField(order: number): BlueprintFieldDefinition {
|
||||
return {
|
||||
id: Math.random().toString(36).slice(2),
|
||||
key: "",
|
||||
label: "",
|
||||
type: FieldType.TEXT,
|
||||
required: false,
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OptionsEditor — for SELECT / MULTI_SELECT
|
||||
// ---------------------------------------------------------------------------
|
||||
interface OptionsEditorProps {
|
||||
options: FieldOption[];
|
||||
onChange: (options: FieldOption[]) => void;
|
||||
}
|
||||
|
||||
function OptionsEditor({ options, onChange }: OptionsEditorProps) {
|
||||
function addOption() {
|
||||
onChange([...options, { value: "", label: "" }]);
|
||||
}
|
||||
|
||||
function removeOption(idx: number) {
|
||||
onChange(options.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function updateOption(idx: number, field: "value" | "label", val: string) {
|
||||
const next = options.map((o, i) =>
|
||||
i === idx ? { ...o, [field]: val } : o,
|
||||
);
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<p className="text-xs font-medium text-gray-600">Options</p>
|
||||
{options.map((opt, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={opt.value}
|
||||
onChange={(e) => updateOption(idx, "value", e.target.value)}
|
||||
placeholder="value"
|
||||
className={`${INPUT_CLS} flex-1`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={opt.label}
|
||||
onChange={(e) => updateOption(idx, "label", e.target.value)}
|
||||
placeholder="label"
|
||||
className={`${INPUT_CLS} flex-1`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(idx)}
|
||||
className={BTN_DANGER}
|
||||
aria-label="Remove option"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addOption}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
+ Add option
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FieldRow — a single field definition row
|
||||
// ---------------------------------------------------------------------------
|
||||
interface FieldRowProps {
|
||||
field: BlueprintFieldDefinition;
|
||||
onChange: (field: BlueprintFieldDefinition) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const needsOptions =
|
||||
field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
|
||||
|
||||
function update<K extends keyof BlueprintFieldDefinition>(
|
||||
key: K,
|
||||
value: BlueprintFieldDefinition[K],
|
||||
) {
|
||||
onChange({ ...field, [key]: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-3 bg-white">
|
||||
{/* Main row */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Drag handle placeholder */}
|
||||
<span className="text-gray-300 cursor-grab select-none text-lg leading-none">
|
||||
☰
|
||||
</span>
|
||||
|
||||
{/* Key */}
|
||||
<input
|
||||
type="text"
|
||||
value={field.key}
|
||||
onChange={(e) => update("key", e.target.value)}
|
||||
placeholder="field_key"
|
||||
className={`${INPUT_CLS} w-36 font-mono`}
|
||||
aria-label="Field key"
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<input
|
||||
type="text"
|
||||
value={field.label}
|
||||
onChange={(e) => update("label", e.target.value)}
|
||||
placeholder="Label"
|
||||
className={`${INPUT_CLS} w-40`}
|
||||
aria-label="Field label"
|
||||
/>
|
||||
|
||||
{/* Type */}
|
||||
<select
|
||||
value={field.type}
|
||||
onChange={(e) => {
|
||||
const t = e.target.value as FieldType;
|
||||
// Clear options when switching away from select types
|
||||
const clearedOptions =
|
||||
t === FieldType.SELECT || t === FieldType.MULTI_SELECT
|
||||
? field.options ?? []
|
||||
: undefined;
|
||||
onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition);
|
||||
}}
|
||||
className={`${INPUT_CLS} w-36`}
|
||||
aria-label="Field type"
|
||||
>
|
||||
{FIELD_TYPES.map((ft) => (
|
||||
<option key={ft.value} value={ft.value}>
|
||||
{ft.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Required */}
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) => update("required", e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Req.
|
||||
</label>
|
||||
|
||||
{/* Expand/Collapse optional fields */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 ml-auto whitespace-nowrap"
|
||||
aria-label={expanded ? "Collapse options" : "Expand options"}
|
||||
>
|
||||
{expanded ? "▲ less" : "▼ more"}
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className={BTN_DANGER}
|
||||
aria-label="Delete field"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded optional fields */}
|
||||
{expanded && (
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500 font-medium">Group</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.group ?? ""}
|
||||
onChange={(e) => update("group", e.target.value || undefined)}
|
||||
placeholder="Section heading"
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500 font-medium">
|
||||
Placeholder
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.placeholder ?? ""}
|
||||
onChange={(e) =>
|
||||
update("placeholder", e.target.value || undefined)
|
||||
}
|
||||
placeholder="Placeholder text"
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500 font-medium">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.description ?? ""}
|
||||
onChange={(e) =>
|
||||
update("description", e.target.value || undefined)
|
||||
}
|
||||
placeholder="Helper text"
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 col-span-full pt-1">
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.showInList ?? false}
|
||||
onChange={(e) => update("showInList", e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Show in list view
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.isFilterable ?? false}
|
||||
onChange={(e) => update("isFilterable", e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Filterable
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{needsOptions && (
|
||||
<div className="col-span-full">
|
||||
<OptionsEditor
|
||||
options={field.options ?? []}
|
||||
onChange={(opts) => update("options", opts)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options inline hint when collapsed */}
|
||||
{!expanded && needsOptions && (field.options?.length ?? 0) === 0 && (
|
||||
<p className="mt-1 text-xs text-amber-600">
|
||||
No options defined yet — click ▼ more to add them.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BlueprintFieldEditor — the modal
|
||||
// ---------------------------------------------------------------------------
|
||||
interface BlueprintFieldEditorProps {
|
||||
blueprintId: string;
|
||||
blueprintName: string;
|
||||
initialFieldDefs: BlueprintFieldDefinition[];
|
||||
initialRolePresets?: StaffingRequirement[];
|
||||
initialTab?: "fields" | "presets";
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BlueprintFieldEditor({
|
||||
blueprintId,
|
||||
blueprintName,
|
||||
initialFieldDefs,
|
||||
initialRolePresets = [],
|
||||
initialTab = "fields",
|
||||
onClose,
|
||||
}: BlueprintFieldEditorProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab);
|
||||
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(
|
||||
() =>
|
||||
[...initialFieldDefs].sort((a, b) => a.order - b.order),
|
||||
);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
|
||||
|
||||
const updateMutation = trpc.blueprint.update.useMutation();
|
||||
|
||||
const presetMutation = trpc.blueprint.updateRolePresets.useMutation();
|
||||
|
||||
function addField() {
|
||||
setFields((prev) => [...prev, makeEmptyField(prev.length)]);
|
||||
}
|
||||
|
||||
function removeField(idx: number) {
|
||||
setFields((prev) =>
|
||||
prev
|
||||
.filter((_, i) => i !== idx)
|
||||
.map((f, i) => ({ ...f, order: i })),
|
||||
);
|
||||
}
|
||||
|
||||
function updateField(idx: number, updated: BlueprintFieldDefinition) {
|
||||
setFields((prev) =>
|
||||
prev.map((f, i) => (i === idx ? updated : f)),
|
||||
);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setSaveError(null);
|
||||
// Reassign order by current list position
|
||||
const normalised = fields.map((f, i) => ({ ...f, order: i }));
|
||||
updateMutation.mutate(
|
||||
{
|
||||
id: blueprintId,
|
||||
data: { fieldDefs: normalised },
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.blueprint.list.invalidate();
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setSaveError(err.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Close on backdrop click
|
||||
function handleBackdropClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Edit Fields:{" "}
|
||||
<span className="text-gray-600 font-normal">{blueprintName}</span>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 px-6">
|
||||
{(["fields", "presets"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
activeTab === tab
|
||||
? "border-brand-500 text-brand-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{tab === "fields" ? "Fields" : "Role Presets"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "fields" ? (
|
||||
<>
|
||||
{/* Field list */}
|
||||
<div className="px-6 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-8">
|
||||
No fields yet. Click “+ Add Field” to get started.
|
||||
</p>
|
||||
)}
|
||||
{fields.map((field, idx) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
field={field}
|
||||
onChange={(updated) => updateField(idx, updated)}
|
||||
onDelete={() => removeField(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add field button */}
|
||||
<div className="px-6 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addField}
|
||||
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
<span className="text-lg leading-none">+</span> Add Field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{saveError && (
|
||||
<div className="mx-6 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className={BTN_PRIMARY}
|
||||
>
|
||||
{updateMutation.isPending ? "Saving…" : "Save Fields"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-6 py-4">
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this blueprint is selected.
|
||||
</p>
|
||||
<RolePresetsEditor
|
||||
initialPresets={initialRolePresets}
|
||||
onSave={(presets) =>
|
||||
presetMutation.mutate(
|
||||
{ id: blueprintId, rolePresets: presets },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.blueprint.list.invalidate();
|
||||
setPresetSaveError(null);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setPresetSaveError(err.message);
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
isSaving={presetMutation.isPending}
|
||||
saveError={presetSaveError}
|
||||
/>
|
||||
<div className="flex justify-start mt-4 border-t border-gray-200 pt-4">
|
||||
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FormEvent, MouseEvent } from "react";
|
||||
import { BlueprintTarget } from "@planarchy/shared";
|
||||
import type { BlueprintFieldDefinition } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { BlueprintFieldEditor } from "./BlueprintFieldEditor.js";
|
||||
import { useSelection } from "~/hooks/useSelection.js";
|
||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
import { FilterBar } from "~/components/ui/FilterBar.js";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
|
||||
const INPUT_CLS =
|
||||
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
|
||||
|
||||
const BTN_PRIMARY =
|
||||
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
|
||||
|
||||
const BTN_SECONDARY =
|
||||
"px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium";
|
||||
|
||||
interface NewBlueprintModalProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
type BlueprintTargetValue = "RESOURCE" | "PROJECT";
|
||||
type BlueprintSortField = "name" | "target" | "fieldCount" | "presetCount" | "global";
|
||||
|
||||
function NewBlueprintModal({ onClose, onCreated }: NewBlueprintModalProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [target, setTarget] = useState<BlueprintTargetValue>("RESOURCE");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = trpc.blueprint.create.useMutation();
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!name.trim()) {
|
||||
setError("Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
target: target as BlueprintTarget,
|
||||
fieldDefs: [],
|
||||
defaults: {},
|
||||
validationRules: [],
|
||||
});
|
||||
await utils.blueprint.list.invalidate();
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create blueprint.");
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent<HTMLDivElement>) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8" onClick={handleBackdropClick}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">New Blueprint</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none" aria-label="Close">×</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-gray-700">Name <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Resource Extended Fields" className={INPUT_CLS} autoFocus />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional description" className={`${INPUT_CLS} resize-none`} rows={2} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-gray-700">Target</label>
|
||||
<select value={target} onChange={(e) => setTarget(e.target.value as BlueprintTargetValue)} className={INPUT_CLS}>
|
||||
<option value="RESOURCE">Resource</option>
|
||||
<option value="PROJECT">Project</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</p>}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className={BTN_SECONDARY}>Cancel</button>
|
||||
<button type="submit" disabled={createMutation.isPending} className={BTN_PRIMARY}>
|
||||
{createMutation.isPending ? "Creating…" : "Create Blueprint"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BlueprintRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
target: BlueprintTargetValue;
|
||||
fieldDefs: unknown;
|
||||
rolePresets: unknown;
|
||||
isGlobal?: boolean;
|
||||
}
|
||||
|
||||
interface BlueprintCardProps {
|
||||
blueprint: BlueprintRow;
|
||||
onEditFields: () => void;
|
||||
onEditStaffing: () => void;
|
||||
onToggleGlobal: () => void;
|
||||
onDelete: () => void;
|
||||
isSelected: boolean;
|
||||
onToggleSelect: () => void;
|
||||
}
|
||||
|
||||
function BlueprintCard({
|
||||
blueprint,
|
||||
onEditFields,
|
||||
onEditStaffing,
|
||||
onToggleGlobal,
|
||||
onDelete,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
}: BlueprintCardProps) {
|
||||
const fieldDefs = Array.isArray(blueprint.fieldDefs) ? (blueprint.fieldDefs as BlueprintFieldDefinition[]) : [];
|
||||
const rolePresets = Array.isArray(blueprint.rolePresets) ? (blueprint.rolePresets as unknown[]) : [];
|
||||
const fieldCount = fieldDefs.length;
|
||||
const presetCount = rolePresets.length;
|
||||
const isProject = blueprint.target === "PROJECT";
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border p-5 flex flex-col gap-3 hover:shadow-sm transition-shadow ${isSelected ? "border-brand-400 bg-brand-50" : "border-gray-200"}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={onToggleSelect}
|
||||
className="mt-0.5 rounded border-gray-300"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{blueprint.name}</h3>
|
||||
{blueprint.description && (
|
||||
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{blueprint.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`shrink-0 inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700" : "bg-blue-50 text-blue-700"}`}>
|
||||
{blueprint.target}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-sm text-gray-500">
|
||||
<span>{fieldCount === 0 ? "No fields" : `${fieldCount} field${fieldCount === 1 ? "" : "s"}`}</span>
|
||||
{isProject && (
|
||||
<span className={presetCount > 0 ? "text-brand-600 font-medium" : ""}>
|
||||
{presetCount === 0 ? "No staffing presets" : `${presetCount} staffing preset${presetCount === 1 ? "" : "s"}`}
|
||||
</span>
|
||||
)}
|
||||
{blueprint.isGlobal && (
|
||||
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 font-medium">Global</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-gray-100">
|
||||
<button type="button" onClick={onEditFields} className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors">
|
||||
Edit Fields
|
||||
</button>
|
||||
{isProject && (
|
||||
<button type="button" onClick={onEditStaffing} className="px-3 py-1.5 border border-brand-300 text-brand-700 rounded-lg hover:bg-brand-50 text-sm font-medium transition-colors">
|
||||
Edit Staffing Presets
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleGlobal}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 ${
|
||||
blueprint.isGlobal
|
||||
? "border border-amber-300 text-amber-700 hover:bg-amber-50"
|
||||
: "border border-gray-200 text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
title={blueprint.isGlobal ? "Remove from global columns" : "Make fields available as global columns"}
|
||||
>
|
||||
{blueprint.isGlobal ? "Unglobalize" : "Make Global"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete blueprint "${blueprint.name}"?`)) {
|
||||
onDelete();
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 border border-red-200 text-red-600 rounded-lg hover:bg-red-50 text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlueprintsClient() {
|
||||
const [showNewModal, setShowNewModal] = useState(false);
|
||||
const [editingBlueprint, setEditingBlueprint] = useState<BlueprintRow | null>(null);
|
||||
const [editingTab, setEditingTab] = useState<"fields" | "presets">("fields");
|
||||
const [targetFilter, setTargetFilter] = useState<string>("");
|
||||
const [confirmBatchDelete, setConfirmBatchDelete] = useState<string[] | null>(null);
|
||||
|
||||
const selection = useSelection();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data, isLoading, isError } = trpc.blueprint.list.useQuery({
|
||||
target: (targetFilter as BlueprintTarget) || undefined,
|
||||
});
|
||||
|
||||
const batchDeleteMutation = trpc.blueprint.batchDelete.useMutation();
|
||||
const deleteMutation = trpc.blueprint.delete.useMutation();
|
||||
const setGlobalMutation = trpc.blueprint.setGlobal.useMutation();
|
||||
|
||||
const viewPrefs = useViewPrefs("blueprints");
|
||||
|
||||
useEffect(() => {
|
||||
selection.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [targetFilter]);
|
||||
|
||||
const blueprints: BlueprintRow[] = data ?? [];
|
||||
const { sorted: sortedBlueprints, sortField, sortDir, toggle } = useTableSort<BlueprintRow, BlueprintSortField>(blueprints, {
|
||||
initialField: (viewPrefs.savedSort?.field as BlueprintSortField | undefined) ?? null,
|
||||
initialDir: viewPrefs.savedSort?.dir ?? null,
|
||||
onSortChange: (field, dir) => {
|
||||
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
||||
},
|
||||
});
|
||||
const blueprintIds = sortedBlueprints.map((b) => b.id);
|
||||
|
||||
function handleSort(field: BlueprintSortField) {
|
||||
switch (field) {
|
||||
case "fieldCount":
|
||||
toggle(field, (row) => (Array.isArray(row.fieldDefs) ? row.fieldDefs.length : 0));
|
||||
return;
|
||||
case "presetCount":
|
||||
toggle(field, (row) => (Array.isArray(row.rolePresets) ? row.rolePresets.length : 0));
|
||||
return;
|
||||
case "global":
|
||||
toggle(field, (row) => (row.isGlobal ? 0 : 1));
|
||||
return;
|
||||
default:
|
||||
toggle(field);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortRequest(field: string) {
|
||||
handleSort(field as BlueprintSortField);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
await deleteMutation.mutateAsync({ id });
|
||||
await utils.blueprint.list.invalidate();
|
||||
if (selection.selectedIds.has(id)) {
|
||||
selection.toggle(id);
|
||||
}
|
||||
if (editingBlueprint?.id === id) {
|
||||
setEditingBlueprint(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleGlobal(id: string, isGlobal: boolean | undefined) {
|
||||
await setGlobalMutation.mutateAsync({ id, isGlobal: !isGlobal });
|
||||
await utils.blueprint.list.invalidate();
|
||||
}
|
||||
|
||||
async function handleBatchDelete(ids: string[]) {
|
||||
await batchDeleteMutation.mutateAsync({ ids });
|
||||
await utils.blueprint.list.invalidate();
|
||||
selection.clear();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24">
|
||||
<div className="flex items-start justify-between mb-6 gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Blueprints</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Configure dynamic fields for resources and projects</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
|
||||
+ New Blueprint
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
hasActiveFilters={!!targetFilter}
|
||||
onClearFilters={() => setTargetFilter("")}
|
||||
>
|
||||
<select
|
||||
value={targetFilter}
|
||||
onChange={(e) => setTargetFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
|
||||
>
|
||||
<option value="">All Targets</option>
|
||||
<option value="RESOURCE">Resource</option>
|
||||
<option value="PROJECT">Project</option>
|
||||
</select>
|
||||
</FilterBar>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-white rounded-xl border border-gray-200 p-5 animate-pulse h-36" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-red-700 text-sm">
|
||||
Failed to load blueprints. Please refresh the page.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && blueprints.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<p className="text-gray-400 text-sm mb-4">No blueprints yet. Create one to start defining dynamic fields.</p>
|
||||
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
|
||||
+ New Blueprint
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && sortedBlueprints.length > 0 && (
|
||||
<>
|
||||
<div className="hidden md:block bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="w-12 px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.isAllSelected(blueprintIds)}
|
||||
onChange={() => selection.toggleAll(blueprintIds)}
|
||||
className="rounded border-gray-300"
|
||||
aria-label="Select all blueprints"
|
||||
/>
|
||||
</th>
|
||||
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} />
|
||||
<SortableColumnHeader label="Target" field="target" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} />
|
||||
<SortableColumnHeader label="Fields" field="fieldCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
|
||||
<SortableColumnHeader label="Staffing Presets" field="presetCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
|
||||
<SortableColumnHeader label="Global" field="global" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
|
||||
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedBlueprints.map((bp) => {
|
||||
const fieldCount = Array.isArray(bp.fieldDefs) ? bp.fieldDefs.length : 0;
|
||||
const presetCount = Array.isArray(bp.rolePresets) ? bp.rolePresets.length : 0;
|
||||
const isProject = bp.target === "PROJECT";
|
||||
|
||||
return (
|
||||
<tr key={bp.id} className="border-b border-gray-100 last:border-b-0 hover:bg-gray-50 transition-colors">
|
||||
<td className="px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.selectedIds.has(bp.id)}
|
||||
onChange={() => selection.toggle(bp.id)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-900">{bp.name}</div>
|
||||
{bp.description && <div className="text-xs text-gray-500 mt-0.5 truncate">{bp.description}</div>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700" : "bg-blue-50 text-blue-700"}`}>
|
||||
{bp.target}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center text-gray-600">{fieldCount}</td>
|
||||
<td className="px-3 py-3 text-center text-gray-600">{isProject ? presetCount : "—"}</td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
{bp.isGlobal ? (
|
||||
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 font-medium">
|
||||
Global
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingTab("fields"); setEditingBlueprint(bp); }}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Edit Fields
|
||||
</button>
|
||||
{isProject && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingTab("presets"); setEditingBlueprint(bp); }}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Presets
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleGlobal(bp.id, bp.isGlobal)}
|
||||
disabled={setGlobalMutation.isPending}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{bp.isGlobal ? "Unglobalize" : "Make Global"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete blueprint "${bp.name}"?`)) {
|
||||
handleDelete(bp.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:hidden">
|
||||
{sortedBlueprints.map((bp) => (
|
||||
<BlueprintCard
|
||||
key={bp.id}
|
||||
blueprint={bp}
|
||||
onEditFields={() => { setEditingTab("fields"); setEditingBlueprint(bp); }}
|
||||
onEditStaffing={() => { setEditingTab("presets"); setEditingBlueprint(bp); }}
|
||||
onToggleGlobal={() => handleToggleGlobal(bp.id, bp.isGlobal)}
|
||||
onDelete={() => handleDelete(bp.id)}
|
||||
isSelected={selection.selectedIds.has(bp.id)}
|
||||
onToggleSelect={() => selection.toggle(bp.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<BatchActionBar
|
||||
count={selection.count}
|
||||
onClear={selection.clear}
|
||||
actions={[
|
||||
{
|
||||
label: `Delete (${selection.count})`,
|
||||
variant: "danger",
|
||||
onClick: () => setConfirmBatchDelete(selection.selectedArray),
|
||||
disabled: batchDeleteMutation.isPending,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{confirmBatchDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete Blueprints"
|
||||
message={`Delete ${confirmBatchDelete.length} selected blueprint${confirmBatchDelete.length !== 1 ? "s" : ""}? They will be marked as inactive.`}
|
||||
confirmLabel="Delete All"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
void handleBatchDelete(confirmBatchDelete);
|
||||
setConfirmBatchDelete(null);
|
||||
}}
|
||||
onCancel={() => setConfirmBatchDelete(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showNewModal && (
|
||||
<NewBlueprintModal onClose={() => setShowNewModal(false)} onCreated={() => setShowNewModal(false)} />
|
||||
)}
|
||||
|
||||
{editingBlueprint && (
|
||||
<BlueprintFieldEditor
|
||||
blueprintId={editingBlueprint.id}
|
||||
blueprintName={editingBlueprint.name}
|
||||
initialFieldDefs={Array.isArray(editingBlueprint.fieldDefs) ? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[]) : []}
|
||||
initialRolePresets={Array.isArray(editingBlueprint.rolePresets) ? (editingBlueprint.rolePresets as import("@planarchy/shared").StaffingRequirement[]) : []}
|
||||
initialTab={editingTab}
|
||||
onClose={() => setEditingBlueprint(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { StaffingRequirement } from "@planarchy/shared";
|
||||
|
||||
const INPUT_CLS =
|
||||
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
|
||||
|
||||
const BTN_DANGER =
|
||||
"px-2 py-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded text-sm transition-colors";
|
||||
|
||||
function makeEmptyPreset(): StaffingRequirement {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
role: "",
|
||||
requiredSkills: [],
|
||||
preferredSkills: [],
|
||||
hoursPerDay: 8,
|
||||
headcount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface PresetRowProps {
|
||||
preset: StaffingRequirement;
|
||||
onChange: (preset: StaffingRequirement) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function PresetRow({ preset, onChange, onDelete }: PresetRowProps) {
|
||||
function update<K extends keyof StaffingRequirement>(key: K, value: StaffingRequirement[K]) {
|
||||
onChange({ ...preset, [key]: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-3 bg-white">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Role */}
|
||||
<input
|
||||
type="text"
|
||||
value={preset.role}
|
||||
onChange={(e) => update("role", e.target.value)}
|
||||
placeholder="Role name"
|
||||
className={`${INPUT_CLS} flex-1 min-w-32`}
|
||||
aria-label="Role name"
|
||||
/>
|
||||
|
||||
{/* Required Skills */}
|
||||
<div className="flex flex-col gap-0.5 flex-1 min-w-40">
|
||||
<label className="text-xs text-gray-400">Required skills (comma-sep)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={preset.requiredSkills.join(", ")}
|
||||
onChange={(e) =>
|
||||
update(
|
||||
"requiredSkills",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder="e.g. 3D Modeling, Lighting"
|
||||
className={INPUT_CLS}
|
||||
aria-label="Required skills"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours per day */}
|
||||
<div className="flex flex-col gap-0.5 w-24">
|
||||
<label className="text-xs text-gray-400">h/day</label>
|
||||
<input
|
||||
type="number"
|
||||
value={preset.hoursPerDay}
|
||||
min={0}
|
||||
max={24}
|
||||
step={0.5}
|
||||
onChange={(e) => update("hoursPerDay", parseFloat(e.target.value) || 0)}
|
||||
className={INPUT_CLS}
|
||||
aria-label="Hours per day"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Headcount */}
|
||||
<div className="flex flex-col gap-0.5 w-20">
|
||||
<label className="text-xs text-gray-400">Count</label>
|
||||
<input
|
||||
type="number"
|
||||
value={preset.headcount}
|
||||
min={1}
|
||||
max={20}
|
||||
onChange={(e) => update("headcount", parseInt(e.target.value, 10) || 1)}
|
||||
className={INPUT_CLS}
|
||||
aria-label="Headcount"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className={`${BTN_DANGER} self-end mb-0.5`}
|
||||
aria-label="Remove preset"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preferred skills (secondary row) */}
|
||||
<div className="mt-2">
|
||||
<label className="text-xs text-gray-400">Preferred skills (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(preset.preferredSkills ?? []).join(", ")}
|
||||
onChange={(e) =>
|
||||
update(
|
||||
"preferredSkills",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder="e.g. Compositing, Art Direction"
|
||||
className={`${INPUT_CLS} w-full mt-0.5`}
|
||||
aria-label="Preferred skills"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RolePresetsEditorProps {
|
||||
initialPresets: StaffingRequirement[];
|
||||
/** Called with the current presets array when the user clicks Save */
|
||||
onSave: (presets: StaffingRequirement[]) => void;
|
||||
isSaving?: boolean;
|
||||
saveError?: string | null;
|
||||
}
|
||||
|
||||
export function RolePresetsEditor({
|
||||
initialPresets,
|
||||
onSave,
|
||||
isSaving = false,
|
||||
saveError = null,
|
||||
}: RolePresetsEditorProps) {
|
||||
const [presets, setPresets] = useState<StaffingRequirement[]>(initialPresets);
|
||||
|
||||
function addPreset() {
|
||||
setPresets((prev) => [...prev, makeEmptyPreset()]);
|
||||
}
|
||||
|
||||
function removePreset(idx: number) {
|
||||
setPresets((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function updatePreset(idx: number, updated: StaffingRequirement) {
|
||||
setPresets((prev) => prev.map((p, i) => (i === idx ? updated : p)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
|
||||
{presets.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-8">
|
||||
No role presets yet. Click “+ Add Role” to define default staffing.
|
||||
</p>
|
||||
)}
|
||||
{presets.map((preset, idx) => (
|
||||
<PresetRow
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
onChange={(updated) => updatePreset(idx, updated)}
|
||||
onDelete={() => removePreset(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPreset}
|
||||
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
<span className="text-lg leading-none">+</span> Add Role
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="mt-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(presets)}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Presets"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardWidgetType } from "@planarchy/shared/types";
|
||||
import { WIDGET_CATALOG } from "./widget-registry.js";
|
||||
|
||||
interface AddWidgetModalProps {
|
||||
onAdd: (type: DashboardWidgetType) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AddWidgetModal({ onAdd, onClose }: AddWidgetModalProps) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Add Widget</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid of widgets */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{WIDGET_CATALOG.map((def) => (
|
||||
<button
|
||||
key={def.type}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAdd(def.type);
|
||||
onClose();
|
||||
}}
|
||||
className="flex items-start gap-4 p-4 border border-gray-200 rounded-xl hover:border-brand-400 hover:bg-brand-50 transition-colors text-left"
|
||||
>
|
||||
<span className="text-3xl shrink-0">{def.icon}</span>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 text-sm">{def.label}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{def.description}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Default: {def.defaultSize.w}×{def.defaultSize.h} grid units
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardWidgetConfig, DashboardWidgetType } from "@planarchy/shared/types";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Suspense, useState, useRef, useEffect } from "react";
|
||||
import { useDashboardLayout } from "~/hooks/useDashboardLayout.js";
|
||||
import { WidgetContainer } from "./WidgetContainer.js";
|
||||
import { AddWidgetModal } from "./AddWidgetModal.js";
|
||||
import { getWidget } from "./widget-registry.js";
|
||||
|
||||
// Import CSS for react-grid-layout
|
||||
import "react-grid-layout/css/styles.css";
|
||||
import "react-resizable/css/styles.css";
|
||||
|
||||
function WidgetFallback() {
|
||||
return (
|
||||
<div className="animate-pulse h-full w-full flex flex-col gap-3 p-4">
|
||||
<div className="h-3 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-full bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-4/5 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-3/5 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Dynamic import — no WidthProvider (uses findDOMNode, broken in React 18 strict mode).
|
||||
// We measure container width ourselves via ResizeObserver and pass it as a prop.
|
||||
const GridLayout = dynamic(() => import("react-grid-layout").then((m) => m.Responsive), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
function renderWidget(type: DashboardWidgetType, config: DashboardWidgetConfig, onConfigChange: (u: Record<string, unknown>) => void) {
|
||||
const widget = getWidget(type);
|
||||
const Component = widget.component;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<WidgetFallback />}>
|
||||
<Component config={config as Record<string, unknown>} onConfigChange={onConfigChange} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardClient() {
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const { config, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout } =
|
||||
useDashboardLayout();
|
||||
|
||||
// Measure grid container width so Responsive knows the column size.
|
||||
// We can't use WidthProvider (uses findDOMNode, deprecated in React 18).
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [gridWidth, setGridWidth] = useState(1200);
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
if (entry) setGridWidth(entry.contentRect.width);
|
||||
});
|
||||
ro.observe(el);
|
||||
setGridWidth(el.getBoundingClientRect().width);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const layouts = {
|
||||
lg: config.widgets.map((w) => ({
|
||||
i: w.id,
|
||||
x: w.x,
|
||||
y: w.y,
|
||||
w: w.w,
|
||||
h: w.h,
|
||||
minW: w.minW ?? 2,
|
||||
minH: w.minH ?? 2,
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Toolbar */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Drag to rearrange, resize from corners</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetLayout}
|
||||
className="px-3 py-2 text-sm text-gray-500 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Widget
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{config.widgets.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center border-2 border-dashed border-gray-200 rounded-xl">
|
||||
<p className="text-gray-400 text-sm mb-4">No widgets yet. Add your first widget to get started.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||||
>
|
||||
+ Add Widget
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={containerRef}>
|
||||
{(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const AnyGridLayout = GridLayout as any;
|
||||
return (
|
||||
<AnyGridLayout
|
||||
className="layout"
|
||||
layouts={layouts}
|
||||
width={gridWidth}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={80}
|
||||
compactType={null}
|
||||
preventCollision={false}
|
||||
onLayoutChange={(_: unknown, allLayouts: Record<string, { i: string; x: number; y: number; w: number; h: number }[]>) => onLayoutChange(allLayouts["lg"] ?? [])}
|
||||
draggableHandle=".widget-drag-handle"
|
||||
margin={[12, 12]}
|
||||
>
|
||||
{config.widgets.map((widget) => (
|
||||
<div key={widget.id}>
|
||||
<WidgetContainer
|
||||
title={widget.title ?? getWidget(widget.type).label}
|
||||
onRemove={() => removeWidget(widget.id)}
|
||||
>
|
||||
{renderWidget(
|
||||
widget.type,
|
||||
widget.config,
|
||||
(update) => updateWidgetConfig(widget.id, update),
|
||||
)}
|
||||
</WidgetContainer>
|
||||
</div>
|
||||
))}
|
||||
</AnyGridLayout>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addModalOpen && (
|
||||
<AddWidgetModal onAdd={addWidget} onClose={() => setAddModalOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
interface WidgetContainerProps {
|
||||
title: string;
|
||||
onRemove: () => void;
|
||||
children: React.ReactNode;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
export function WidgetContainer({ title, onRemove, children, isDragging }: WidgetContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col h-full bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden ${
|
||||
isDragging ? "shadow-lg border-brand-300" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-100 bg-gray-50/50 shrink-0 cursor-grab active:cursor-grabbing widget-drag-handle">
|
||||
<span className="text-sm font-semibold text-gray-700 truncate">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="ml-2 p-1 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded transition-colors shrink-0"
|
||||
title="Remove widget"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
DASHBOARD_WIDGET_CATALOG,
|
||||
type DashboardWidgetCatalogEntry,
|
||||
type DashboardWidgetType,
|
||||
} from "@planarchy/shared/types";
|
||||
import { lazy, type ComponentType, type LazyExoticComponent } from "react";
|
||||
|
||||
type WidgetUpdate = Record<string, unknown>;
|
||||
|
||||
export interface WidgetProps {
|
||||
config: Record<string, unknown>;
|
||||
onConfigChange?: (update: WidgetUpdate) => void;
|
||||
}
|
||||
|
||||
export type WidgetComponent = LazyExoticComponent<ComponentType<WidgetProps>>;
|
||||
|
||||
export interface WidgetDefinition extends DashboardWidgetCatalogEntry {
|
||||
component: WidgetComponent;
|
||||
}
|
||||
|
||||
export const WIDGET_CATALOG = DASHBOARD_WIDGET_CATALOG;
|
||||
|
||||
export const WIDGET_REGISTRY: Record<DashboardWidgetType, WidgetDefinition> = {
|
||||
"stat-cards": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "stat-cards")!,
|
||||
component: lazy(() => import("./widgets/StatCardsWidget.js").then((m) => ({ default: m.StatCardsWidget }))),
|
||||
},
|
||||
"resource-table": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "resource-table")!,
|
||||
component: lazy(() => import("./widgets/ResourceTableWidget.js").then((m) => ({ default: m.ResourceTableWidget }))),
|
||||
},
|
||||
"project-table": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "project-table")!,
|
||||
component: lazy(() => import("./widgets/ProjectTableWidget.js").then((m) => ({ default: m.ProjectTableWidget }))),
|
||||
},
|
||||
"peak-times-chart": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "peak-times-chart")!,
|
||||
component: lazy(() => import("./widgets/PeakTimesWidget.js").then((m) => ({ default: m.PeakTimesWidget }))),
|
||||
},
|
||||
"demand-view": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "demand-view")!,
|
||||
component: lazy(() => import("./widgets/DemandWidget.js").then((m) => ({ default: m.DemandWidget }))),
|
||||
},
|
||||
"top-value-resources": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "top-value-resources")!,
|
||||
component: lazy(() => import("./widgets/TopValueWidget.js").then((m) => ({ default: m.TopValueWidget }))),
|
||||
},
|
||||
"chargeability-overview": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "chargeability-overview")!,
|
||||
component: lazy(() => import("./widgets/ChargeabilityWidget.js").then((m) => ({ default: m.ChargeabilityWidget }))),
|
||||
},
|
||||
};
|
||||
|
||||
export function getWidget(type: DashboardWidgetType): WidgetDefinition {
|
||||
return WIDGET_REGISTRY[type];
|
||||
}
|
||||
|
||||
export function getAllWidgets(): WidgetDefinition[] {
|
||||
return Object.values(WIDGET_REGISTRY);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
type TopSortKey = "name" | "actual" | "expected";
|
||||
type WatchSortKey = "name" | "actual" | "target";
|
||||
|
||||
export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
const config = _config as { topN?: number; watchlistThreshold?: number };
|
||||
const [topSort, setTopSort] = useState<TopSortKey>("actual");
|
||||
const [topDir, setTopDir] = useState<"asc" | "desc">("desc");
|
||||
const [watchSort, setWatchSort] = useState<WatchSortKey>("actual");
|
||||
const [watchDir, setWatchDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
function toggleTop(key: TopSortKey) {
|
||||
if (topSort === key) setTopDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setTopSort(key); setTopDir(key === "name" ? "asc" : "desc"); }
|
||||
}
|
||||
function toggleWatch(key: WatchSortKey) {
|
||||
if (watchSort === key) setWatchDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setWatchSort(key); setWatchDir(key === "name" ? "asc" : "asc"); }
|
||||
}
|
||||
|
||||
const { data, isLoading } = trpc.dashboard.getChargeabilityOverview.useQuery(
|
||||
{ topN: config.topN ?? 10, watchlistThreshold: config.watchlistThreshold ?? 15 },
|
||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col gap-3 pt-1">
|
||||
<div className="h-2 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3 px-2 py-1">
|
||||
<div className="h-3 w-4 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 mt-1 pt-2">
|
||||
<div className="h-2 w-20 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3 px-2 py-1">
|
||||
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rawTop = data?.top ?? [];
|
||||
const rawWatch = data?.watchlist ?? [];
|
||||
const month = data?.month ?? "";
|
||||
|
||||
const top = [...rawTop].sort((a, b) => {
|
||||
const mult = topDir === "asc" ? 1 : -1;
|
||||
switch (topSort) {
|
||||
case "name": return mult * a.displayName.localeCompare(b.displayName);
|
||||
case "actual": return mult * (a.actualChargeability - b.actualChargeability);
|
||||
case "expected": return mult * (a.expectedChargeability - b.expectedChargeability);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const watchlist = [...rawWatch].sort((a, b) => {
|
||||
const mult = watchDir === "asc" ? 1 : -1;
|
||||
switch (watchSort) {
|
||||
case "name": return mult * a.displayName.localeCompare(b.displayName);
|
||||
case "actual": return mult * (a.actualChargeability - b.actualChargeability);
|
||||
case "target": return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
function TopInd({ k }: { k: TopSortKey }) {
|
||||
return topSort === k
|
||||
? <span className="text-[10px] ml-0.5">{topDir === "asc" ? "▲" : "▼"}</span>
|
||||
: <span className="text-[10px] ml-0.5 text-gray-300">⇅</span>;
|
||||
}
|
||||
function WatchInd({ k }: { k: WatchSortKey }) {
|
||||
return watchSort === k
|
||||
? <span className="text-[10px] ml-0.5">{watchDir === "asc" ? "▲" : "▼"}</span>
|
||||
: <span className="text-[10px] ml-0.5 text-gray-300">⇅</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-2 overflow-hidden">
|
||||
{month && (
|
||||
<p className="text-xs text-gray-400 px-1 flex-shrink-0 flex items-center gap-1">
|
||||
Period: {month}
|
||||
<InfoTooltip
|
||||
content="Chargeability is calculated for the current calendar month. Available hours are based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
|
||||
width="w-72"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Top list */}
|
||||
<section className="flex-1 min-h-0 overflow-auto">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
|
||||
Top Chargeability
|
||||
</h3>
|
||||
{top.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 px-1">No data available.</p>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-400 border-b border-gray-100">
|
||||
<th className="px-2 py-1 text-left font-medium w-6">#</th>
|
||||
<th className="px-2 py-1 text-left font-medium">
|
||||
<button type="button" onClick={() => toggleTop("name")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Name<TopInd k="name" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-2 py-1 text-right font-medium">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleTop("actual")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Actual<TopInd k="actual" />
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content="CONFIRMED + ACTIVE allocations on non-DRAFT projects ÷ available working hours this month × 100."
|
||||
width="w-72"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-2 py-1 text-right font-medium">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleTop("expected")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Expected<TopInd k="expected" />
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content="All non-CANCELLED allocations (including DRAFT projects and PROPOSED status) ÷ available working hours this month × 100."
|
||||
width="w-72"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{top.map((r, i) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50">
|
||||
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
|
||||
<td className="px-2 py-1 text-gray-800 truncate max-w-[120px]">
|
||||
<span title={r.displayName}>{r.displayName}</span>
|
||||
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right font-semibold text-green-700">
|
||||
{r.actualChargeability}%
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">
|
||||
{r.expectedChargeability}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="border-t border-gray-100 flex-shrink-0" />
|
||||
|
||||
{/* Watchlist */}
|
||||
<section className="flex-1 min-h-0 overflow-auto">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
|
||||
Watchlist <span className="font-normal text-gray-400">(below target)</span>
|
||||
</h3>
|
||||
{watchlist.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 px-1">All resources at or near target.</p>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-400 border-b border-gray-100">
|
||||
<th className="px-2 py-1 text-left font-medium">
|
||||
<button type="button" onClick={() => toggleWatch("name")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Name<WatchInd k="name" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-2 py-1 text-right font-medium">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleWatch("actual")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Actual<WatchInd k="actual" />
|
||||
</button>
|
||||
<InfoTooltip content="Actual chargeability this month: CONFIRMED + ACTIVE allocations on non-DRAFT projects ÷ available hours." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-2 py-1 text-right font-medium">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleWatch("target")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Target<WatchInd k="target" />
|
||||
</button>
|
||||
<InfoTooltip content="Chargeability target set by management. Watchlist shows resources more than 15 percentage points below their target." />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{watchlist.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50">
|
||||
<td className="px-2 py-1 text-gray-800 truncate max-w-[140px]">
|
||||
<span title={r.displayName}>{r.displayName}</span>
|
||||
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right font-semibold text-red-600">
|
||||
{r.actualChargeability}%
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">
|
||||
{r.chargeabilityTarget}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
type GroupBy = "project" | "person" | "chapter";
|
||||
|
||||
export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const groupBy = (config.groupBy as GroupBy) || "project";
|
||||
|
||||
type SortKey = "name" | "allocatedHours" | "requiredFTEs" | "resourceCount";
|
||||
const [sortKey, setSortKey] = useState<SortKey>("allocatedHours");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir(key === "name" ? "asc" : "desc"); }
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 3, 0).toISOString();
|
||||
|
||||
const { data, isLoading, isFetching } = trpc.dashboard.getDemand.useQuery(
|
||||
{ startDate, endDate, groupBy },
|
||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col gap-3 pt-1">
|
||||
<div className="flex gap-1 border-b border-gray-200 pb-1">
|
||||
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3 px-3 py-1.5">
|
||||
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const mult = sortDir === "asc" ? 1 : -1;
|
||||
switch (sortKey) {
|
||||
case "name": return mult * a.name.localeCompare(b.name);
|
||||
case "allocatedHours": return mult * (a.allocatedHours - b.allocatedHours);
|
||||
case "requiredFTEs": return mult * ((a.requiredFTEs as unknown as number ?? 0) - (b.requiredFTEs as unknown as number ?? 0));
|
||||
case "resourceCount": return mult * (a.resourceCount - b.resourceCount);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
function Ind({ k }: { k: SortKey }) {
|
||||
return sortKey === k
|
||||
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "▲" : "▼"}</span>
|
||||
: <span className="text-[10px] ml-0.5 text-gray-300">⇅</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-gray-200">
|
||||
{(["project", "person", "chapter"] as GroupBy[]).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
onClick={() => onConfigChange?.({ groupBy: g })}
|
||||
className={`px-3 py-1.5 text-xs font-medium capitalize transition-colors ${
|
||||
groupBy === g
|
||||
? "border-b-2 border-brand-600 text-brand-700"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
Per {g === "person" ? "Person" : g === "project" ? "Project" : "Chapter"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className={`overflow-auto flex-1 transition-opacity duration-150 ${isFetching ? "opacity-60" : "opacity-100"}`}>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
{groupBy === "project" ? "Project" : groupBy === "person" ? "Person" : "Chapter"}
|
||||
<Ind k="name" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("allocatedHours")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
Allocated h<Ind k="allocatedHours" />
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content="Total booked hours from active assignments in the current quarter."
|
||||
position="bottom"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
{groupBy === "project" && (
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("requiredFTEs")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
Req. FTEs<Ind k="requiredFTEs" />
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content="Planned demand from demand requirements, with fallback to project staffing requirements for legacy projects. Red = booked hours fall short of the planned demand."
|
||||
position="bottom"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
)}
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("resourceCount")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
{groupBy === "person" ? "Projects" : "Resources"}<Ind k="resourceCount" />
|
||||
</button>
|
||||
{groupBy === "person" ? (
|
||||
<InfoTooltip content="Number of distinct projects this person is allocated to in the period." position="bottom" />
|
||||
) : (
|
||||
<InfoTooltip content="Number of distinct resources allocated to this project/chapter in the period." position="bottom" />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-medium text-gray-900 max-w-[200px] truncate">
|
||||
{groupBy === "project" ? (
|
||||
<span><span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>{row.name}</span>
|
||||
) : (
|
||||
row.name
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{row.allocatedHours}h</td>
|
||||
{groupBy === "project" && (
|
||||
<td className="px-3 py-2 text-right text-gray-700">
|
||||
{(() => {
|
||||
const ftes = row.requiredFTEs as unknown as number;
|
||||
return ftes > 0 ? (
|
||||
<span className={row.allocatedHours / 8 < ftes * 22 * 3 ? "text-red-600 font-semibold" : "text-green-700"}>
|
||||
{ftes} FTE
|
||||
</span>
|
||||
) : "—";
|
||||
})()}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-2 text-right text-gray-500">{row.resourceCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-gray-400">No demand data found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
|
||||
const COLORS = [
|
||||
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
|
||||
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6",
|
||||
];
|
||||
|
||||
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const granularity = (config.granularity as "week" | "month") || "month";
|
||||
const groupBy = (config.groupBy as "project" | "chapter" | "resource") || "project";
|
||||
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString();
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0).toISOString();
|
||||
|
||||
const { data, isLoading } = trpc.dashboard.getPeakTimes.useQuery(
|
||||
{ startDate, endDate, granularity, groupBy },
|
||||
{ staleTime: 120_000, placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col gap-3 h-full pt-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-7 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
<div className="h-7 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex items-end gap-1 flex-1 px-2">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-t"
|
||||
style={{ height: `${30 + Math.random() * 50}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const periods = data ?? [];
|
||||
|
||||
// Collect all group names
|
||||
const allGroups = new Set<string>();
|
||||
for (const p of periods) {
|
||||
for (const g of p.groups) allGroups.add(g.name);
|
||||
}
|
||||
const groups = [...allGroups].slice(0, 10);
|
||||
|
||||
// Build recharts data
|
||||
const chartData = periods.map((p) => {
|
||||
const row: Record<string, number | string> = { period: p.period, capacity: p.capacityHours };
|
||||
for (const g of p.groups) {
|
||||
row[g.name] = g.hours;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Controls + info */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<select
|
||||
value={granularity}
|
||||
onChange={(e) => onConfigChange?.({ granularity: e.target.value })}
|
||||
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
|
||||
>
|
||||
<option value="month">Monthly</option>
|
||||
<option value="week">Weekly</option>
|
||||
</select>
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => onConfigChange?.({ groupBy: e.target.value })}
|
||||
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
|
||||
>
|
||||
<option value="project">By Project</option>
|
||||
<option value="chapter">By Chapter</option>
|
||||
<option value="resource">By Resource</option>
|
||||
</select>
|
||||
<InfoTooltip
|
||||
content={
|
||||
<span>
|
||||
Stacked bars = booked hours per group per period (last 2 months to next 6 months).<br />
|
||||
Red dashed line = total capacity estimate (all active resources × available hours per day × working days).<br />
|
||||
Bars exceeding the capacity line indicate over-allocation risk.
|
||||
</span>
|
||||
}
|
||||
width="w-80"
|
||||
position="bottom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{chartData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
||||
No allocation data in selected period.
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="period" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ fontSize: 11 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<ReferenceLine
|
||||
{...({ dataKey: "capacity" } as any)}
|
||||
stroke="#ef4444"
|
||||
strokeDasharray="5 5"
|
||||
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }}
|
||||
/>
|
||||
{groups.map((g, i) => (
|
||||
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { ProjectStatus } from "@planarchy/shared/types";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: "bg-gray-100 text-gray-700",
|
||||
ACTIVE: "bg-green-100 text-green-700",
|
||||
ON_HOLD: "bg-yellow-100 text-yellow-700",
|
||||
COMPLETED: "bg-blue-100 text-blue-700",
|
||||
CANCELLED: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const status = (config.status as ProjectStatus) || undefined;
|
||||
const search = (config.search as string) || "";
|
||||
|
||||
const { data: projects, isLoading } = trpc.project.listWithCosts.useQuery(
|
||||
{ status, search: search || undefined },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
type SortKey = "code" | "name" | "status" | "cost" | "personDays";
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col gap-2 pt-1">
|
||||
{/* header row */}
|
||||
<div className="flex gap-3 px-3 py-2">
|
||||
{[40, 120, 80, 60, 60].map((w, i) => (
|
||||
<div key={i} className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded" style={{ width: w }} />
|
||||
))}
|
||||
</div>
|
||||
{/* data rows */}
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3 px-3 py-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
|
||||
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
shortCode: string;
|
||||
name: string;
|
||||
status: string;
|
||||
totalCostCents: number;
|
||||
totalPersonDays: number;
|
||||
}
|
||||
const list = ((projects as unknown as { projects: ProjectRow[] } | undefined)?.projects ?? []) as ProjectRow[];
|
||||
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
const mult = sortDir === "asc" ? 1 : -1;
|
||||
switch (sortKey) {
|
||||
case "code": return mult * a.shortCode.localeCompare(b.shortCode);
|
||||
case "name": return mult * a.name.localeCompare(b.name);
|
||||
case "status": return mult * a.status.localeCompare(b.status);
|
||||
case "cost": return mult * (a.totalCostCents - b.totalCostCents);
|
||||
case "personDays": return mult * (a.totalPersonDays - b.totalPersonDays);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search projects..."
|
||||
value={search}
|
||||
onChange={(e) => onConfigChange?.({ search: e.target.value })}
|
||||
className="flex-1 min-w-0 px-2 py-1 text-xs border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<select
|
||||
value={status ?? ""}
|
||||
onChange={(e) => onConfigChange?.({ status: e.target.value || undefined })}
|
||||
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{Object.values(ProjectStatus).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-auto flex-1">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("code")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Code
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "code" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Name
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "name" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("status")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Status
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "status" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("cost")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Cost
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "cost" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content="Sum of (resource LCR × hours per day × working days) across all non-cancelled allocations on this project."
|
||||
width="w-72"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("personDays")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Person Days
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "personDays" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip content="Total working days allocated across all non-cancelled allocations (sum of allocation durations in working days)." />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-mono text-gray-600">{p.shortCode}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-900 max-w-[180px] truncate">{p.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded-full text-xs ${STATUS_COLORS[p.status] ?? ""}`}>
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">
|
||||
{(p.totalCostCents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} €
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{p.totalPersonDays}d</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-gray-400">No projects found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
interface ResourceRow {
|
||||
id: string;
|
||||
eid: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
chargeabilityTarget: number;
|
||||
bookingCount: number;
|
||||
utilizationPercent: number;
|
||||
isOverbooked: boolean;
|
||||
}
|
||||
|
||||
export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const chapter = (config.chapter as string) || "";
|
||||
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 3, 0).toISOString();
|
||||
|
||||
const { data: resources, isLoading } = trpc.resource.listWithUtilization.useQuery(
|
||||
{ chapter: chapter || undefined, startDate, endDate },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const { data: chapterData } = trpc.resource.chapters.useQuery(undefined, { staleTime: 120_000 });
|
||||
const chapters = chapterData ?? [];
|
||||
|
||||
type SortKey = "eid" | "name" | "chapter" | "bookings" | "utilization" | "target";
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col gap-2 pt-1">
|
||||
{/* header row */}
|
||||
<div className="flex gap-3 px-3 py-2">
|
||||
{[40, 120, 80, 60, 60].map((w, i) => (
|
||||
<div key={i} className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded" style={{ width: w }} />
|
||||
))}
|
||||
</div>
|
||||
{/* data rows */}
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3 px-3 py-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
|
||||
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const list = (resources ?? []) as unknown as ResourceRow[];
|
||||
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
const mult = sortDir === "asc" ? 1 : -1;
|
||||
switch (sortKey) {
|
||||
case "eid": return mult * a.eid.localeCompare(b.eid);
|
||||
case "name": return mult * a.displayName.localeCompare(b.displayName);
|
||||
case "chapter": return mult * (a.chapter ?? "").localeCompare(b.chapter ?? "");
|
||||
case "bookings": return mult * (a.bookingCount - b.bookingCount);
|
||||
case "utilization": return mult * (a.utilizationPercent - b.utilizationPercent);
|
||||
case "target": return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Filter */}
|
||||
{chapters.length > 0 && (
|
||||
<select
|
||||
value={chapter}
|
||||
onChange={(e) => onConfigChange?.({ chapter: e.target.value })}
|
||||
className="w-40 px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
|
||||
>
|
||||
<option value="">All Chapters</option>
|
||||
{chapters.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-auto flex-1">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<span className="inline-flex items-center">
|
||||
<button type="button" onClick={() => toggleSort("eid")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
EID
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "eid" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip content="Employee ID — unique identifier for each resource." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Name
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "name" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("chapter")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Chapter
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "chapter" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("bookings")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Bookings
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "bookings" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip content="Number of non-cancelled allocations in the period (current month + next 3 months)." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("utilization")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Utilization
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "utilization" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content={
|
||||
<span>
|
||||
Booked hours ÷ available hours × 100 for the period.<br />
|
||||
Available hours = working days × hours from personal schedule.<br />
|
||||
<span className="text-orange-300">Orange</span> = >85% · <span className="text-red-300">Red</span> = >100%
|
||||
</span>
|
||||
}
|
||||
width="w-72"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("target")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Target
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "target" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip content="Chargeability target set by management per resource. Not a computed value." />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.map((r) => (
|
||||
<tr key={r.id} className={`hover:bg-gray-50 ${r.isOverbooked ? "bg-amber-50" : ""}`}>
|
||||
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{r.bookingCount}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className={`font-semibold ${r.utilizationPercent > 100 ? "text-red-600" : r.utilizationPercent > 85 ? "text-orange-600" : "text-green-700"}`}>
|
||||
{r.utilizationPercent}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-500">{r.chargeabilityTarget}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-gray-400">No resources found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
function formatMoney(cents: number): string {
|
||||
return (cents / 100).toLocaleString("de-DE") + " EUR";
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub, info }: { label: string; value: string | number; sub?: string; info?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-gray-500 flex items-center">
|
||||
{label}
|
||||
{info && <InfoTooltip content={info} />}
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-gray-900">{value}</span>
|
||||
{sub && <span className="text-xs text-gray-400">{sub}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
|
||||
const { data, isLoading } = trpc.dashboard.getOverview.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 h-full animate-pulse">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="rounded-xl bg-gray-100 dark:bg-gray-800 p-4 flex flex-col gap-2">
|
||||
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-7 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-2 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 h-full content-start">
|
||||
<StatCard
|
||||
label="Total Resources"
|
||||
value={data.totalResources}
|
||||
sub={`${data.activeResources} active`}
|
||||
info="All resources in the system. Sub-line shows active resources only."
|
||||
/>
|
||||
<StatCard
|
||||
label="Active Projects"
|
||||
value={data.activeProjects}
|
||||
sub={`${data.totalProjects} total`}
|
||||
info="Projects with status ACTIVE. Total includes all statuses (DRAFT, ON_HOLD, COMPLETED, CANCELLED)."
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Allocations"
|
||||
value={data.totalAllocations}
|
||||
sub={`${data.activeAllocations} not cancelled`}
|
||||
info="All allocation records ever created. 'Not cancelled' excludes allocations with status CANCELLED."
|
||||
/>
|
||||
<StatCard
|
||||
label="Budget Utilization"
|
||||
value={`${data.budgetSummary.avgUtilizationPercent}%`}
|
||||
sub={`${formatMoney(data.budgetSummary.totalCostCents)} of ${formatMoney(data.budgetSummary.totalBudgetCents)}`}
|
||||
info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost = resource LCR × booked hours."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
type SortKey = "eid" | "name" | "chapter" | "score" | "lcr";
|
||||
|
||||
export function TopValueWidget({ config }: WidgetProps) {
|
||||
const limit = (config.limit as number) || 10;
|
||||
|
||||
const [sortKey, setSortKey] = useState<SortKey>("score");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir(key === "score" ? "desc" : "asc"); }
|
||||
}
|
||||
|
||||
const { data, isLoading } = trpc.dashboard.getTopValueResources.useQuery(
|
||||
{ limit },
|
||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col gap-1 pt-1">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="h-3 w-4 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
|
||||
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-5 w-10 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const list = data ?? [];
|
||||
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center py-8 text-gray-400 text-sm">
|
||||
<p>No scores computed yet or you lack access.</p>
|
||||
<p className="text-xs mt-1">Admins can recompute scores in Settings.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
const mult = sortDir === "asc" ? 1 : -1;
|
||||
switch (sortKey) {
|
||||
case "eid": return mult * a.eid.localeCompare(b.eid);
|
||||
case "name": return mult * a.displayName.localeCompare(b.displayName);
|
||||
case "chapter": return mult * (a.chapter ?? "").localeCompare(b.chapter ?? "");
|
||||
case "score": return mult * ((a.valueScore ?? 0) - (b.valueScore ?? 0));
|
||||
case "lcr": return mult * (a.lcrCents - b.lcrCents);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
function Ind({ k }: { k: SortKey }) {
|
||||
return sortKey === k
|
||||
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "▲" : "▼"}</span>
|
||||
: <span className="text-[10px] ml-0.5 text-gray-300">⇅</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500 w-8">#</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("eid")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
EID<Ind k="eid" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
Name<Ind k="name" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("chapter")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
Chapter<Ind k="chapter" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("score")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
Score<Ind k="score" />
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content={
|
||||
<span>
|
||||
Composite price/quality score 0–100.<br />
|
||||
Weights: Skill Depth 30% · Cost Efficiency 25% · Skill Breadth 15% · Chargeability 15% · Experience 15%.<br />
|
||||
Recompute in Admin → Settings.
|
||||
</span>
|
||||
}
|
||||
width="w-72"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("lcr")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
LCR (€)<Ind k="lcr" />
|
||||
</button>
|
||||
<InfoTooltip content="Labour Cost Rate — hourly cost in EUR. Lower LCR = better cost efficiency score." />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.map((r, i) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 text-gray-400 font-medium">{i + 1}</td>
|
||||
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full font-semibold ${
|
||||
(r.valueScore ?? 0) >= 70
|
||||
? "bg-green-100 text-green-700"
|
||||
: (r.valueScore ?? 0) >= 40
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{r.valueScore ?? "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{(r.lcrCents / 100).toFixed(0)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
import { FieldType } from "@planarchy/shared";
|
||||
import type { BlueprintFieldDefinition } from "@planarchy/shared";
|
||||
|
||||
interface Props {
|
||||
fieldDefs: BlueprintFieldDefinition[];
|
||||
values: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
errors?: Record<string, string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const INPUT_BASE =
|
||||
"w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors";
|
||||
|
||||
const INPUT_NORMAL = "border-gray-300 bg-white text-gray-900";
|
||||
const INPUT_ERROR = "border-red-400 bg-red-50 text-gray-900";
|
||||
|
||||
function inputClass(hasError: boolean) {
|
||||
return clsx(INPUT_BASE, hasError ? INPUT_ERROR : INPUT_NORMAL);
|
||||
}
|
||||
|
||||
interface FieldInputProps {
|
||||
fieldDef: BlueprintFieldDefinition;
|
||||
value: unknown;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
|
||||
const { key, type, placeholder, validation, options } = fieldDef;
|
||||
|
||||
switch (type) {
|
||||
case FieldType.TEXT:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
id={key}
|
||||
value={typeof value === "string" ? value : (value ?? "") as string}
|
||||
placeholder={placeholder}
|
||||
maxLength={validation?.maxLength}
|
||||
minLength={validation?.minLength}
|
||||
onChange={(e) => onChange(key, e.target.value)}
|
||||
className={inputClass(hasError)}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldType.TEXTAREA:
|
||||
return (
|
||||
<textarea
|
||||
id={key}
|
||||
value={typeof value === "string" ? value : (value ?? "") as string}
|
||||
placeholder={placeholder}
|
||||
maxLength={validation?.maxLength}
|
||||
onChange={(e) => onChange(key, e.target.value)}
|
||||
className={clsx(inputClass(hasError), "resize-y min-h-[80px]")}
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldType.NUMBER:
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
id={key}
|
||||
value={value !== undefined && value !== null && value !== "" ? Number(value) : ""}
|
||||
placeholder={placeholder}
|
||||
min={validation?.min}
|
||||
max={validation?.max}
|
||||
onChange={(e) =>
|
||||
onChange(key, e.target.value === "" ? "" : Number(e.target.value))
|
||||
}
|
||||
className={inputClass(hasError)}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldType.BOOLEAN: {
|
||||
const checked = value === true || value === "true" || value === 1;
|
||||
return (
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={key}
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(key, e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{checked ? "Yes" : "No"}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
case FieldType.DATE:
|
||||
return (
|
||||
<DateInput
|
||||
id={key}
|
||||
value={typeof value === "string" ? value : (value ?? "") as string}
|
||||
onChange={(v) => onChange(key, v)}
|
||||
className={inputClass(hasError)}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldType.SELECT:
|
||||
return (
|
||||
<select
|
||||
id={key}
|
||||
value={typeof value === "string" ? value : (value ?? "") as string}
|
||||
onChange={(e) => onChange(key, e.target.value)}
|
||||
className={inputClass(hasError)}
|
||||
>
|
||||
<option value="">{placeholder ?? "Select…"}</option>
|
||||
{(options ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
case FieldType.MULTI_SELECT: {
|
||||
const selectedVals = Array.isArray(value) ? value.map(String) : [];
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{(options ?? []).map((opt) => {
|
||||
const checked = selectedVals.includes(opt.value);
|
||||
return (
|
||||
<label key={opt.value} className="inline-flex items-center gap-2 cursor-pointer mr-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={opt.value}
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked
|
||||
? [...selectedVals, opt.value]
|
||||
: selectedVals.filter((v) => v !== opt.value);
|
||||
onChange(key, next);
|
||||
}}
|
||||
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{opt.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case FieldType.URL:
|
||||
return (
|
||||
<input
|
||||
type="url"
|
||||
id={key}
|
||||
value={typeof value === "string" ? value : (value ?? "") as string}
|
||||
placeholder={placeholder ?? "https://"}
|
||||
onChange={(e) => onChange(key, e.target.value)}
|
||||
className={inputClass(hasError)}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldType.EMAIL:
|
||||
return (
|
||||
<input
|
||||
type="email"
|
||||
id={key}
|
||||
value={typeof value === "string" ? value : (value ?? "") as string}
|
||||
placeholder={placeholder ?? "email@example.com"}
|
||||
onChange={(e) => onChange(key, e.target.value)}
|
||||
className={inputClass(hasError)}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
id={key}
|
||||
value={typeof value === "string" ? value : (value ?? "") as string}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(key, e.target.value)}
|
||||
className={inputClass(hasError)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface FieldWrapperProps {
|
||||
fieldDef: BlueprintFieldDefinition;
|
||||
value: unknown;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function FieldWrapper({ fieldDef, value, onChange, error }: FieldWrapperProps) {
|
||||
const hasError = Boolean(error);
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={fieldDef.key}
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
{fieldDef.label}
|
||||
{fieldDef.required && (
|
||||
<span className="ml-0.5 text-red-500" aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<FieldInput
|
||||
fieldDef={fieldDef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={hasError}
|
||||
/>
|
||||
|
||||
{fieldDef.description && !error && (
|
||||
<p className="text-xs text-gray-400">{fieldDef.description}</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({
|
||||
group,
|
||||
fields,
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
}: {
|
||||
group: string;
|
||||
fields: BlueprintFieldDefinition[];
|
||||
values: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
errors?: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="space-y-4 border border-gray-200 rounded-lg p-4">
|
||||
<legend className="text-sm font-semibold text-gray-700 px-1">{group}</legend>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{fields.map((field) => (
|
||||
<FieldWrapper
|
||||
key={field.id}
|
||||
fieldDef={field}
|
||||
value={values[field.key]}
|
||||
onChange={onChange}
|
||||
{...(errors?.[field.key] !== undefined ? { error: errors[field.key] } : {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export function DynamicFieldEditor({
|
||||
fieldDefs,
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
className,
|
||||
}: Props) {
|
||||
const sorted = [...fieldDefs].sort((a, b) => a.order - b.order);
|
||||
|
||||
const ungrouped = sorted.filter((f) => !f.group);
|
||||
const groupMap = new Map<string, BlueprintFieldDefinition[]>();
|
||||
|
||||
for (const field of sorted) {
|
||||
if (!field.group) continue;
|
||||
const existing = groupMap.get(field.group) ?? [];
|
||||
existing.push(field);
|
||||
groupMap.set(field.group, existing);
|
||||
}
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("space-y-6", className)}>
|
||||
{ungrouped.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{ungrouped.map((field) => (
|
||||
<FieldWrapper
|
||||
key={field.id}
|
||||
fieldDef={field}
|
||||
value={values[field.key]}
|
||||
onChange={onChange}
|
||||
{...(errors?.[field.key] !== undefined ? { error: errors[field.key] } : {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{[...groupMap.entries()].map(([group, fields]) => (
|
||||
<FieldGroup
|
||||
key={group}
|
||||
group={group}
|
||||
fields={fields}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
{...(errors !== undefined ? { errors } : {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { formatDateLong } from "~/lib/format.js";
|
||||
import { FieldType } from "@planarchy/shared";
|
||||
import type { BlueprintFieldDefinition } from "@planarchy/shared";
|
||||
|
||||
interface Props {
|
||||
fieldDefs: BlueprintFieldDefinition[];
|
||||
values: Record<string, unknown>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function renderValue(fieldDef: BlueprintFieldDefinition, value: unknown): React.ReactNode {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return <span className="text-gray-400">—</span>;
|
||||
}
|
||||
|
||||
switch (fieldDef.type) {
|
||||
case FieldType.TEXT:
|
||||
case FieldType.TEXTAREA:
|
||||
case FieldType.URL:
|
||||
case FieldType.EMAIL:
|
||||
return <span className="text-gray-900">{String(value)}</span>;
|
||||
|
||||
case FieldType.NUMBER:
|
||||
return (
|
||||
<span className="text-gray-900">
|
||||
{typeof value === "number" ? value.toLocaleString() : String(value)}
|
||||
</span>
|
||||
);
|
||||
|
||||
case FieldType.BOOLEAN: {
|
||||
const bool = value === true || value === "true" || value === 1;
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
bool
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-gray-100 text-gray-500",
|
||||
)}
|
||||
>
|
||||
{bool ? "Yes" : "No"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
case FieldType.DATE: {
|
||||
const dateStr = String(value);
|
||||
const parsed = new Date(dateStr);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return <span className="text-gray-900">{dateStr}</span>;
|
||||
}
|
||||
return <span className="text-gray-900">{formatDateLong(parsed)}</span>;
|
||||
}
|
||||
|
||||
case FieldType.SELECT: {
|
||||
const strVal = String(value);
|
||||
const option = fieldDef.options?.find((o) => o.value === strVal);
|
||||
return <span className="text-gray-900">{option?.label ?? strVal}</span>;
|
||||
}
|
||||
|
||||
case FieldType.MULTI_SELECT: {
|
||||
const rawVals = Array.isArray(value) ? value : [value];
|
||||
const strVals = rawVals.map((v) => String(v)).filter(Boolean);
|
||||
|
||||
if (strVals.length === 0) {
|
||||
return <span className="text-gray-400">—</span>;
|
||||
}
|
||||
|
||||
const labels = strVals.map((v) => {
|
||||
const option = fieldDef.options?.find((o) => o.value === v);
|
||||
return { value: v, label: option?.label ?? v, color: option?.color };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{labels.map(({ value: v, label }) => (
|
||||
<span
|
||||
key={v}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-brand-50 text-brand-700"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return <span className="text-gray-900">{String(value)}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
function FieldRow({ fieldDef, value }: { fieldDef: BlueprintFieldDefinition; value: unknown }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{fieldDef.label}
|
||||
</dt>
|
||||
<dd className="text-sm">{renderValue(fieldDef, value)}</dd>
|
||||
{fieldDef.description && (
|
||||
<p className="text-xs text-gray-400">{fieldDef.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DynamicFieldRenderer({ fieldDefs, values, className }: Props) {
|
||||
const sorted = [...fieldDefs].sort((a, b) => a.order - b.order);
|
||||
|
||||
// Separate grouped and ungrouped fields
|
||||
const ungrouped = sorted.filter((f) => !f.group);
|
||||
const groupMap = new Map<string, BlueprintFieldDefinition[]>();
|
||||
|
||||
for (const field of sorted) {
|
||||
if (!field.group) continue;
|
||||
const existing = groupMap.get(field.group) ?? [];
|
||||
existing.push(field);
|
||||
groupMap.set(field.group, existing);
|
||||
}
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("space-y-6", className)}>
|
||||
{ungrouped.length > 0 && (
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{ungrouped.map((field) => (
|
||||
<FieldRow key={field.id} fieldDef={field} value={values[field.key]} />
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{[...groupMap.entries()].map(([group, fields]) => (
|
||||
<div key={group} className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 border-b border-gray-200 pb-1">
|
||||
{group}
|
||||
</h4>
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{fields.map((field) => (
|
||||
<FieldRow key={field.id} fieldDef={field} value={values[field.key]} />
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DynamicFieldRenderer } from "./DynamicFieldRenderer.js";
|
||||
export { DynamicFieldEditor } from "./DynamicFieldEditor.js";
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface ApplyEffortRulesProps {
|
||||
estimateId: string;
|
||||
canEdit: boolean;
|
||||
onApplied?: () => void;
|
||||
}
|
||||
|
||||
export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffortRulesProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: ruleSets, isLoading } = trpc.effortRule.list.useQuery();
|
||||
|
||||
const [selectedRuleSetId, setSelectedRuleSetId] = useState<string>("");
|
||||
const [mode, setMode] = useState<"replace" | "append">("replace");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const previewQuery = trpc.effortRule.preview.useQuery(
|
||||
{ estimateId, ruleSetId: selectedRuleSetId },
|
||||
{ enabled: showPreview && Boolean(selectedRuleSetId) },
|
||||
);
|
||||
|
||||
const applyMutation = trpc.effortRule.apply.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.estimate.getById.invalidate();
|
||||
setShowPreview(false);
|
||||
onApplied?.();
|
||||
if (result.warnings.length > 0) {
|
||||
alert(`Generated ${result.linesGenerated} demand lines.\n\nWarnings:\n${result.warnings.join("\n")}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-select default rule set
|
||||
if (!selectedRuleSetId && ruleSets) {
|
||||
const defaultSet = ruleSets.find((rs) => rs.isDefault) ?? ruleSets[0];
|
||||
if (defaultSet) {
|
||||
setSelectedRuleSetId(defaultSet.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-gray-400">Loading effort rules...</p>;
|
||||
}
|
||||
|
||||
if (!ruleSets || ruleSets.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500">
|
||||
No effort rule sets defined.{" "}
|
||||
<a href="/admin/effort-rules" className="text-brand-600 hover:underline">
|
||||
Create one
|
||||
</a>{" "}
|
||||
to auto-generate demand lines from scope items.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Generate demand lines from scope</h3>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Rule set</span>
|
||||
<select
|
||||
value={selectedRuleSetId}
|
||||
onChange={(e) => {
|
||||
setSelectedRuleSetId(e.target.value);
|
||||
setShowPreview(false);
|
||||
}}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
|
||||
>
|
||||
{ruleSets.map((rs) => (
|
||||
<option key={rs.id} value={rs.id}>
|
||||
{rs.name} ({rs.rules.length} rules){rs.isDefault ? " *" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Mode</span>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as "replace" | "append")}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
|
||||
>
|
||||
<option value="replace">Replace existing lines</option>
|
||||
<option value="append">Append to existing</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
disabled={!selectedRuleSetId}
|
||||
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{showPreview ? "Hide preview" : "Preview"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedRuleSetId) return;
|
||||
const action = mode === "replace" ? "replace all existing demand lines" : "append new demand lines";
|
||||
if (confirm(`This will ${action}. Continue?`)) {
|
||||
applyMutation.mutate({ estimateId, ruleSetId: selectedRuleSetId, mode });
|
||||
}
|
||||
}}
|
||||
disabled={!selectedRuleSetId || applyMutation.isPending}
|
||||
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{applyMutation.isPending ? "Generating..." : "Apply rules"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{applyMutation.error && (
|
||||
<p className="mt-2 text-sm text-red-600">{applyMutation.error.message}</p>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{showPreview && previewQuery.data && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
|
||||
<span>{previewQuery.data.scopeItemCount} scope items</span>
|
||||
<span>{previewQuery.data.ruleCount} rules</span>
|
||||
<span className="font-semibold text-brand-700">{previewQuery.data.lines.length} demand lines would be generated</span>
|
||||
{previewQuery.data.unmatchedScopeItems.length > 0 && (
|
||||
<span className="text-amber-600">{previewQuery.data.unmatchedScopeItems.length} unmatched scope items</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewQuery.data.warnings.length > 0 && (
|
||||
<div className="rounded-xl bg-amber-50 p-3 text-sm text-amber-700">
|
||||
{previewQuery.data.warnings.map((w, i) => (
|
||||
<p key={i}>{w}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aggregated discipline summary */}
|
||||
{previewQuery.data.aggregated.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Discipline</th>
|
||||
<th className="px-3 py-2 font-medium">Chapter</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Total hours</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Lines</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewQuery.data.aggregated.map((agg, i) => (
|
||||
<tr key={i} className="border-b border-gray-100">
|
||||
<td className="py-1.5 pr-3 font-medium text-gray-900">{agg.discipline}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{agg.chapter ?? "\u2014"}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">{agg.totalHours.toFixed(1)} h</td>
|
||||
<td className="pl-3 py-1.5 text-right tabular-nums text-gray-500">{agg.lineCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed lines (collapsible) */}
|
||||
{previewQuery.data.lines.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
|
||||
Show all {previewQuery.data.lines.length} generated lines
|
||||
</summary>
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Scope item</th>
|
||||
<th className="px-3 py-2 font-medium">Discipline</th>
|
||||
<th className="px-3 py-2 font-medium">Chapter</th>
|
||||
<th className="px-3 py-2 font-medium">Mode</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Units</th>
|
||||
<th className="px-3 py-2 text-right font-medium">h/unit</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewQuery.data.lines.map((line, i) => (
|
||||
<tr key={i} className={clsx("border-b border-gray-100", i % 2 === 0 ? "" : "bg-gray-50")}>
|
||||
<td className="py-1.5 pr-3 text-gray-900">{line.scopeItemName}</td>
|
||||
<td className="px-3 py-1.5 text-gray-700">{line.discipline}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{line.chapter ?? "\u2014"}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{line.unitMode}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-600">{line.unitCount}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-600">{line.hoursPerUnit}</td>
|
||||
<td className="pl-3 py-1.5 text-right tabular-nums font-medium text-gray-900">{line.hours.toFixed(1)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPreview && previewQuery.isLoading && (
|
||||
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface ApplyExperienceMultipliersProps {
|
||||
estimateId: string;
|
||||
canEdit: boolean;
|
||||
onApplied?: () => void;
|
||||
}
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
return (cents / 100).toLocaleString("de-DE", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: ApplyExperienceMultipliersProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: sets, isLoading } = trpc.experienceMultiplier.list.useQuery();
|
||||
|
||||
const [selectedSetId, setSelectedSetId] = useState<string>("");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const previewQuery = trpc.experienceMultiplier.preview.useQuery(
|
||||
{ estimateId, multiplierSetId: selectedSetId },
|
||||
{ enabled: showPreview && Boolean(selectedSetId) },
|
||||
);
|
||||
|
||||
const applyMutation = trpc.experienceMultiplier.apply.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.estimate.getById.invalidate();
|
||||
setShowPreview(false);
|
||||
onApplied?.();
|
||||
alert(
|
||||
`Updated ${result.linesUpdated} demand line(s).\n` +
|
||||
`Hours: ${result.totalOriginalHours}h -> ${result.totalAdjustedHours}h`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-select default set
|
||||
if (!selectedSetId && sets) {
|
||||
const defaultSet = sets.find((s) => s.isDefault) ?? sets[0];
|
||||
if (defaultSet) {
|
||||
setSelectedSetId(defaultSet.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-gray-400">Loading experience multipliers...</p>;
|
||||
}
|
||||
|
||||
if (!sets || sets.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500">
|
||||
No experience multiplier sets defined.{" "}
|
||||
<a href="/admin/experience-multipliers" className="text-brand-600 hover:underline">
|
||||
Create one
|
||||
</a>{" "}
|
||||
to apply rate and effort adjustments.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Apply experience multipliers</h3>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Multiplier set</span>
|
||||
<select
|
||||
value={selectedSetId}
|
||||
onChange={(e) => {
|
||||
setSelectedSetId(e.target.value);
|
||||
setShowPreview(false);
|
||||
}}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
|
||||
>
|
||||
{sets.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.rules.length} rules){s.isDefault ? " *" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
disabled={!selectedSetId}
|
||||
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{showPreview ? "Hide preview" : "Preview"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedSetId) return;
|
||||
if (confirm("This will update cost/bill rates and hours on matching demand lines. Continue?")) {
|
||||
applyMutation.mutate({ estimateId, multiplierSetId: selectedSetId });
|
||||
}
|
||||
}}
|
||||
disabled={!selectedSetId || applyMutation.isPending}
|
||||
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{applyMutation.isPending ? "Applying..." : "Apply multipliers"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{applyMutation.error && (
|
||||
<p className="mt-2 text-sm text-red-600">{applyMutation.error.message}</p>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{showPreview && previewQuery.data && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
|
||||
<span>{previewQuery.data.demandLineCount} demand lines</span>
|
||||
<span>{previewQuery.data.ruleCount} rules</span>
|
||||
<span className="font-semibold text-brand-700">
|
||||
{previewQuery.data.linesChanged} line(s) would be adjusted
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{previewQuery.data.linesChanged > 0 && (
|
||||
<div className="rounded-xl bg-blue-50 p-3 text-sm text-blue-700">
|
||||
Total cost: {formatCents(previewQuery.data.totalOriginalCostCents)} {"->"}{" "}
|
||||
{formatCents(previewQuery.data.totalAdjustedCostCents)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-line preview */}
|
||||
{previewQuery.data.previews.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
|
||||
Show all {previewQuery.data.previews.length} lines
|
||||
</summary>
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Chapter</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost rate</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Bill rate</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours</th>
|
||||
<th className="pl-3 py-2 font-medium">Changes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewQuery.data.previews.map((p, i) => (
|
||||
<tr
|
||||
key={p.demandLineId}
|
||||
className={clsx(
|
||||
"border-b border-gray-100",
|
||||
p.hasChanges ? "bg-amber-50" : i % 2 === 0 ? "" : "bg-gray-50",
|
||||
)}
|
||||
>
|
||||
<td className="py-1.5 pr-3 text-gray-900">{p.name}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{p.chapter ?? "\u2014"}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
|
||||
{p.hasChanges && p.adjustedCostRateCents !== p.originalCostRateCents ? (
|
||||
<>
|
||||
<span className="line-through text-gray-400">{formatCents(p.originalCostRateCents)}</span>{" "}
|
||||
{formatCents(p.adjustedCostRateCents)}
|
||||
</>
|
||||
) : (
|
||||
formatCents(p.originalCostRateCents)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
|
||||
{p.hasChanges && p.adjustedBillRateCents !== p.originalBillRateCents ? (
|
||||
<>
|
||||
<span className="line-through text-gray-400">{formatCents(p.originalBillRateCents)}</span>{" "}
|
||||
{formatCents(p.adjustedBillRateCents)}
|
||||
</>
|
||||
) : (
|
||||
formatCents(p.originalBillRateCents)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
|
||||
{p.hasChanges && p.adjustedHours !== p.originalHours ? (
|
||||
<>
|
||||
<span className="line-through text-gray-400">{p.originalHours}h</span>{" "}
|
||||
{p.adjustedHours}h
|
||||
</>
|
||||
) : (
|
||||
`${p.originalHours}h`
|
||||
)}
|
||||
</td>
|
||||
<td className="pl-3 py-1.5 text-xs text-gray-500">
|
||||
{p.hasChanges ? p.appliedRules[0] ?? "" : "No change"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPreview && previewQuery.isLoading && (
|
||||
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { EstimateStatus } from "@planarchy/shared";
|
||||
import { computeEvenSpread } from "@planarchy/engine";
|
||||
import { isSpreadsheetFile } from "~/lib/excel.js";
|
||||
import { parseScopeImport } from "~/lib/scopeImportParser.js";
|
||||
import { clsx } from "clsx";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
|
||||
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
const INPUT_CLS =
|
||||
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
|
||||
const SELECT_CLS = INPUT_CLS;
|
||||
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
|
||||
const STEP_LABELS = ["Setup", "Assumptions", "Scope", "Staffing", "Review"];
|
||||
|
||||
interface AssumptionRow {
|
||||
id: string;
|
||||
category: string;
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ScopeRow {
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
scopeType: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface DemandRow {
|
||||
id: string;
|
||||
name: string;
|
||||
roleId: string | null;
|
||||
resourceId: string | null;
|
||||
hours: string;
|
||||
chapter: string;
|
||||
costRate: string;
|
||||
billRate: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface ProjectOption {
|
||||
id: string;
|
||||
shortCode: string;
|
||||
name: string;
|
||||
startDate?: string | Date | null;
|
||||
endDate?: string | Date | null;
|
||||
}
|
||||
|
||||
interface RoleOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ResourceOption {
|
||||
id: string;
|
||||
eid: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
currency: string;
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
roleId: string | null;
|
||||
federalState: string | null;
|
||||
}
|
||||
|
||||
function makeAssumption(): AssumptionRow {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
category: "commercial",
|
||||
key: "",
|
||||
label: "",
|
||||
value: "",
|
||||
};
|
||||
}
|
||||
|
||||
function makeScope(sequenceNo = 1): ScopeRow {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
sequenceNo,
|
||||
scopeType: "SHOT",
|
||||
name: "",
|
||||
description: "",
|
||||
};
|
||||
}
|
||||
|
||||
function makeDemand(): DemandRow {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: "",
|
||||
roleId: null,
|
||||
resourceId: null,
|
||||
hours: "8",
|
||||
chapter: "",
|
||||
costRate: "",
|
||||
billRate: "",
|
||||
currency: "EUR",
|
||||
};
|
||||
}
|
||||
|
||||
function toCents(value: string) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(parsed * 100);
|
||||
}
|
||||
|
||||
function toHours(value: string) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return 0;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatMoney(cents: number, currency = "EUR") {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
}
|
||||
|
||||
export function EstimateWizard({ onClose }: { onClose: () => void }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [name, setName] = useState("");
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [opportunityId, setOpportunityId] = useState("");
|
||||
const [baseCurrency, setBaseCurrency] = useState("EUR");
|
||||
const [status, setStatus] = useState<EstimateStatus>(EstimateStatus.DRAFT);
|
||||
const [versionLabel, setVersionLabel] = useState("Initial");
|
||||
const [versionNotes, setVersionNotes] = useState("");
|
||||
const [assumptions, setAssumptions] = useState<AssumptionRow[]>([makeAssumption()]);
|
||||
const [scopeItems, setScopeItems] = useState<ScopeRow[]>([makeScope(1)]);
|
||||
const [demandLines, setDemandLines] = useState<DemandRow[]>([makeDemand()]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scopeImportWarnings, setScopeImportWarnings] = useState<string[]>([]);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const projectsQuery = trpc.project.list.useQuery({ limit: 200 }, { staleTime: 60_000 });
|
||||
const rolesQuery = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
||||
const resourcesQuery = trpc.resource.list.useQuery(
|
||||
{ limit: 500, includeRoles: true, isActive: true },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const createMutation = trpc.estimate.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.estimate.list.invalidate();
|
||||
onClose();
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setError(mutationError.message);
|
||||
},
|
||||
});
|
||||
|
||||
const projectRows = (projectsQuery.data?.projects ?? []) as unknown as ProjectOption[];
|
||||
const roleRows = (rolesQuery.data ?? []) as unknown as RoleOption[];
|
||||
const resourceRows = (resourcesQuery.data?.resources ?? []) as unknown as ResourceOption[];
|
||||
|
||||
const projects: ProjectOption[] = projectRows.map((project) => ({
|
||||
id: project.id,
|
||||
shortCode: project.shortCode,
|
||||
name: project.name,
|
||||
}));
|
||||
const roles: RoleOption[] = roleRows.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
}));
|
||||
const resources: ResourceOption[] = resourceRows.map((resource) => ({
|
||||
id: resource.id,
|
||||
eid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
chapter: resource.chapter,
|
||||
currency: resource.currency,
|
||||
lcrCents: resource.lcrCents,
|
||||
ucrCents: resource.ucrCents,
|
||||
roleId: resource.roleId,
|
||||
federalState: resource.federalState,
|
||||
}));
|
||||
|
||||
const selectedProject = projectId
|
||||
? projects.find((project) => project.id === projectId) ?? null
|
||||
: null;
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return demandLines.reduce(
|
||||
(accumulator, line) => {
|
||||
const hours = toHours(line.hours);
|
||||
const costTotalCents = Math.round(hours * toCents(line.costRate));
|
||||
const priceTotalCents = Math.round(hours * toCents(line.billRate));
|
||||
|
||||
return {
|
||||
totalHours: accumulator.totalHours + hours,
|
||||
totalCostCents: accumulator.totalCostCents + costTotalCents,
|
||||
totalPriceCents: accumulator.totalPriceCents + priceTotalCents,
|
||||
};
|
||||
},
|
||||
{ totalHours: 0, totalCostCents: 0, totalPriceCents: 0 },
|
||||
);
|
||||
}, [demandLines]);
|
||||
|
||||
const marginCents = summary.totalPriceCents - summary.totalCostCents;
|
||||
const marginPercent = summary.totalPriceCents > 0
|
||||
? Math.round((marginCents / summary.totalPriceCents) * 100)
|
||||
: 0;
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
function updateAssumption(id: string, patch: Partial<AssumptionRow>) {
|
||||
setAssumptions((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
function updateScopeItem(id: string, patch: Partial<ScopeRow>) {
|
||||
setScopeItems((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
function updateDemandLine(id: string, patch: Partial<DemandRow>) {
|
||||
setDemandLines((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
function applyResource(resourceId: string | null, demandLineId: string) {
|
||||
const resource = resourceId
|
||||
? resources.find((item) => item.id === resourceId) ?? null
|
||||
: null;
|
||||
|
||||
updateDemandLine(demandLineId, {
|
||||
resourceId,
|
||||
name: resource?.displayName ?? "",
|
||||
chapter: resource?.chapter ?? "",
|
||||
currency: resource?.currency ?? baseCurrency,
|
||||
costRate: resource ? (resource.lcrCents / 100).toFixed(2) : "",
|
||||
billRate: resource ? (resource.ucrCents / 100).toFixed(2) : "",
|
||||
roleId: resource?.roleId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleScopeImport(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
event.target.value = "";
|
||||
|
||||
if (!isSpreadsheetFile(file)) {
|
||||
setScopeImportWarnings(["Unsupported file type. Please upload .xlsx, .xls, or .csv."]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await parseScopeImport(file);
|
||||
setScopeImportWarnings(result.warnings);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const imported: ScopeRow[] = result.rows.map((row) => ({
|
||||
id: crypto.randomUUID(),
|
||||
sequenceNo: row.sequenceNo,
|
||||
scopeType: row.scopeType,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
}));
|
||||
setScopeItems((current) => {
|
||||
const nonEmpty = current.filter((item) => item.name.trim());
|
||||
return [...nonEmpty, ...imported];
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setScopeImportWarnings(["Failed to parse the file. Please check the format."]);
|
||||
}
|
||||
}
|
||||
|
||||
function validateStep(targetStep: number) {
|
||||
if (targetStep === 1 && !name.trim()) {
|
||||
setError("Estimate name is required.");
|
||||
return false;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
const nextStep = Math.min(step + 1, STEP_LABELS.length - 1);
|
||||
if (!validateStep(nextStep)) return;
|
||||
setStep(nextStep);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
setStep((current) => Math.max(current - 1, 0));
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Estimate name is required.");
|
||||
setStep(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedDemandLines = demandLines
|
||||
.map((line, index) => {
|
||||
const resource = line.resourceId
|
||||
? resources.find((item) => item.id === line.resourceId) ?? null
|
||||
: null;
|
||||
const role = line.roleId
|
||||
? roles.find((item) => item.id === line.roleId) ?? null
|
||||
: null;
|
||||
const hours = toHours(line.hours);
|
||||
const costRateCents = toCents(line.costRate);
|
||||
const billRateCents = toCents(line.billRate);
|
||||
const displayName = line.name.trim() || resource?.displayName || role?.name || `Line ${index + 1}`;
|
||||
|
||||
return {
|
||||
resourceId: line.resourceId ?? undefined,
|
||||
roleId: line.roleId ?? undefined,
|
||||
lineType: "LABOR",
|
||||
name: displayName,
|
||||
chapter: line.chapter || resource?.chapter || undefined,
|
||||
hours,
|
||||
days: hours > 0 ? Number((hours / 8).toFixed(2)) : undefined,
|
||||
rateSource: resource ? "RESOURCE" : role ? "ROLE" : "MANUAL",
|
||||
costRateCents,
|
||||
billRateCents,
|
||||
currency: line.currency || resource?.currency || baseCurrency,
|
||||
costTotalCents: Math.round(hours * costRateCents),
|
||||
priceTotalCents: Math.round(hours * billRateCents),
|
||||
monthlySpread:
|
||||
selectedProject?.startDate && selectedProject?.endDate && hours > 0
|
||||
? computeEvenSpread({
|
||||
totalHours: hours,
|
||||
startDate: new Date(selectedProject.startDate),
|
||||
endDate: new Date(selectedProject.endDate),
|
||||
}).spread
|
||||
: {},
|
||||
staffingAttributes: {
|
||||
linkedResource: resource ? true : false,
|
||||
linkedRole: role ? true : false,
|
||||
},
|
||||
metadata: {},
|
||||
};
|
||||
})
|
||||
.filter((line) => line.hours > 0);
|
||||
|
||||
const normalizedScopeItems = scopeItems
|
||||
.map((item, index) => ({
|
||||
sequenceNo: index + 1,
|
||||
scopeType: item.scopeType.trim() || "SHOT",
|
||||
name: item.name.trim(),
|
||||
description: item.description.trim() || undefined,
|
||||
technicalSpec: {},
|
||||
sortOrder: index,
|
||||
metadata: {},
|
||||
}))
|
||||
.filter((item) => item.name.length > 0);
|
||||
|
||||
const normalizedAssumptions = assumptions
|
||||
.map((assumption, index) => ({
|
||||
category: assumption.category.trim() || "general",
|
||||
key: assumption.key.trim() || slugify(assumption.label) || `assumption_${index + 1}`,
|
||||
label: assumption.label.trim(),
|
||||
valueType: "text",
|
||||
value: assumption.value.trim(),
|
||||
sortOrder: index,
|
||||
}))
|
||||
.filter((assumption) => assumption.label.length > 0 && String(assumption.value).length > 0);
|
||||
|
||||
const seenResources = new Set<string>();
|
||||
const resourceSnapshots = normalizedDemandLines.flatMap((line) => {
|
||||
if (!line.resourceId) return [];
|
||||
if (seenResources.has(line.resourceId)) return [];
|
||||
seenResources.add(line.resourceId);
|
||||
|
||||
const resource = resources.find((item) => item.id === line.resourceId) ?? null;
|
||||
if (!resource) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
resourceId: resource.id,
|
||||
sourceEid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
chapter: resource.chapter ?? undefined,
|
||||
roleId: resource.roleId ?? undefined,
|
||||
currency: resource.currency,
|
||||
lcrCents: resource.lcrCents,
|
||||
ucrCents: resource.ucrCents,
|
||||
location: resource.federalState ?? undefined,
|
||||
attributes: {},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
createMutation.mutate({
|
||||
projectId: projectId ?? undefined,
|
||||
name: name.trim(),
|
||||
opportunityId: opportunityId.trim() || undefined,
|
||||
baseCurrency,
|
||||
status,
|
||||
versionLabel: versionLabel.trim() || undefined,
|
||||
versionNotes: versionNotes.trim() || undefined,
|
||||
assumptions: normalizedAssumptions,
|
||||
scopeItems: normalizedScopeItems,
|
||||
demandLines: normalizedDemandLines,
|
||||
resourceSnapshots,
|
||||
metrics: [],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-950/45 p-4">
|
||||
<div ref={panelRef} className="flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-[32px] bg-white shadow-2xl">
|
||||
<div className="border-b border-gray-100 px-6 py-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Wizard</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Create a connected estimate</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Rates, resource snapshots, and project linkage are pulled from existing Planarchy data.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-500 transition hover:border-gray-300 hover:text-gray-700"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-2 md:grid-cols-5">
|
||||
{STEP_LABELS.map((label, index) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (index <= step || validateStep(index)) {
|
||||
setStep(index);
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
"rounded-2xl px-4 py-3 text-left transition",
|
||||
index === step
|
||||
? "bg-brand-600 text-white"
|
||||
: index < step
|
||||
? "bg-brand-50 text-brand-700"
|
||||
: "bg-gray-50 text-gray-400",
|
||||
)}
|
||||
>
|
||||
<span className="block text-xs uppercase tracking-wide">Step {index + 1}</span>
|
||||
<span className="mt-1 block text-sm font-semibold">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="grid min-h-0 flex-1 gap-0 lg:grid-cols-[minmax(0,1.15fr),360px]">
|
||||
<div className="min-h-0 overflow-y-auto px-6 py-6">
|
||||
{step === 0 && (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Estimate Name</label>
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} className={INPUT_CLS} placeholder="CGI Breakdown Q2 2026" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Linked Project</label>
|
||||
<ProjectCombobox value={projectId} onChange={setProjectId} placeholder="Link to project" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Opportunity ID</label>
|
||||
<input value={opportunityId} onChange={(event) => setOpportunityId(event.target.value)} className={INPUT_CLS} placeholder="Optional CRM or sales reference" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Estimate Status</label>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value as EstimateStatus)} className={SELECT_CLS}>
|
||||
{Object.values(EstimateStatus).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value.replace("_", " ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Base Currency</label>
|
||||
<input value={baseCurrency} onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())} className={INPUT_CLS} maxLength={3} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Version Label</label>
|
||||
<input value={versionLabel} onChange={(event) => setVersionLabel(event.target.value)} className={INPUT_CLS} placeholder="Initial" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Version Notes</label>
|
||||
<textarea
|
||||
value={versionNotes}
|
||||
onChange={(event) => setVersionNotes(event.target.value)}
|
||||
rows={5}
|
||||
className={INPUT_CLS}
|
||||
placeholder="Document assumptions, exclusions, or client comments."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 p-5">
|
||||
<p className="text-sm font-semibold text-gray-900">Live connection preview</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Project source</p>
|
||||
<p className="mt-1 text-sm text-gray-700">
|
||||
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "Not linked yet"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Live catalogs</p>
|
||||
<p className="mt-1 text-sm text-gray-700">
|
||||
{roles.length} roles, {resources.length} active resources available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Commercial and delivery assumptions</h3>
|
||||
<p className="text-sm text-gray-500">These rows replace free-form spreadsheet notes with structured data.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setAssumptions((current) => [...current, makeAssumption()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
Add assumption
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{assumptions.map((row) => (
|
||||
<div key={row.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[140px,1fr,1fr,1.2fr,auto]">
|
||||
<input value={row.category} onChange={(event) => updateAssumption(row.id, { category: event.target.value })} className={INPUT_CLS} placeholder="Category" />
|
||||
<input value={row.label} onChange={(event) => updateAssumption(row.id, { label: event.target.value })} className={INPUT_CLS} placeholder="Label" />
|
||||
<input value={row.key} onChange={(event) => updateAssumption(row.id, { key: event.target.value })} className={INPUT_CLS} placeholder="Key (optional)" />
|
||||
<input value={row.value} onChange={(event) => updateAssumption(row.id, { value: event.target.value })} className={INPUT_CLS} placeholder="Value" />
|
||||
<button type="button" onClick={() => setAssumptions((current) => current.filter((item) => item.id !== row.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Scope breakdown</h3>
|
||||
<p className="text-sm text-gray-500">Create structured work packages that can later evolve into versioned estimate scope.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<label className="cursor-pointer rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
Import XLSX
|
||||
<input type="file" accept=".xlsx,.xls,.csv" onChange={handleScopeImport} className="hidden" />
|
||||
</label>
|
||||
<button type="button" onClick={() => setScopeItems((current) => [...current, makeScope(current.length + 1)])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
Add scope row
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scopeImportWarnings.length > 0 && (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
{scopeImportWarnings.map((warning, index) => (
|
||||
<p key={index}>{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{scopeItems.map((item, index) => (
|
||||
<div key={item.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[90px,120px,1fr,1.2fr,auto]">
|
||||
<input value={String(index + 1)} readOnly className={clsx(INPUT_CLS, "bg-gray-50 text-gray-500")} />
|
||||
<input value={item.scopeType} onChange={(event) => updateScopeItem(item.id, { scopeType: event.target.value })} className={INPUT_CLS} placeholder="Type" />
|
||||
<input value={item.name} onChange={(event) => updateScopeItem(item.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Name" />
|
||||
<input value={item.description} onChange={(event) => updateScopeItem(item.id, { description: event.target.value })} className={INPUT_CLS} placeholder="Description" />
|
||||
<button type="button" onClick={() => setScopeItems((current) => current.filter((row) => row.id !== item.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Staffing and rate lines</h3>
|
||||
<p className="text-sm text-gray-500">Selecting a resource pre-fills cost rate, sell rate, chapter, and role from live data.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setDemandLines((current) => [...current, makeDemand()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
Add staffing line
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{demandLines.map((line) => {
|
||||
const resource = line.resourceId
|
||||
? resources.find((item) => item.id === line.resourceId) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={line.id} className="rounded-3xl border border-gray-100 p-4">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Resource</label>
|
||||
<ResourceCombobox value={line.resourceId} onChange={(resourceId) => applyResource(resourceId, line.id)} placeholder="Search resource" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Role</label>
|
||||
<select value={line.roleId ?? ""} onChange={(event) => updateDemandLine(line.id, { roleId: event.target.value || null })} className={SELECT_CLS}>
|
||||
<option value="">Unassigned</option>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Line Name</label>
|
||||
<input value={line.name} onChange={(event) => updateDemandLine(line.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Compositing, lighting, PM, ..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Chapter</label>
|
||||
<input value={line.chapter} onChange={(event) => updateDemandLine(line.id, { chapter: event.target.value })} className={INPUT_CLS} placeholder="Auto-filled from resource when linked" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Hours</label>
|
||||
<input value={line.hours} onChange={(event) => updateDemandLine(line.id, { hours: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Currency</label>
|
||||
<input value={line.currency} onChange={(event) => updateDemandLine(line.id, { currency: event.target.value.toUpperCase() })} className={INPUT_CLS} maxLength={3} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Cost Rate / h</label>
|
||||
<input value={line.costRate} onChange={(event) => updateDemandLine(line.id, { costRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Sell Rate / h</label>
|
||||
<input value={line.billRate} onChange={(event) => updateDemandLine(line.id, { billRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
{resource ? `Linked to ${resource.displayName} (${resource.eid})` : "Manual line"}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<span className="font-medium text-gray-700">
|
||||
Cost {formatMoney(Math.round(toHours(line.hours) * toCents(line.costRate)), line.currency)}
|
||||
</span>
|
||||
<span className="font-medium text-gray-700">
|
||||
Price {formatMoney(Math.round(toHours(line.hours) * toCents(line.billRate)), line.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => setDemandLines((current) => current.filter((item) => item.id !== line.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Review</h3>
|
||||
<p className="text-sm text-gray-500">The summary metrics below are recalculated from the demand rows and persisted on create.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{summary.totalHours.toFixed(1)}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalCostCents, baseCurrency)}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalPriceCents, baseCurrency)}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Margin</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">
|
||||
{formatMoney(marginCents, baseCurrency)} ({marginPercent}%)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<div className="rounded-3xl border border-gray-100 p-5">
|
||||
<p className="text-sm font-semibold text-gray-900">Estimate envelope</p>
|
||||
<dl className="mt-4 space-y-2 text-sm text-gray-600">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Name</dt>
|
||||
<dd className="text-right text-gray-900">{name || "Untitled"}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Project</dt>
|
||||
<dd className="text-right text-gray-900">{selectedProject ? `${selectedProject.shortCode}` : "Standalone"}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Status</dt>
|
||||
<dd className="text-right text-gray-900">{status.replace("_", " ")}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Version</dt>
|
||||
<dd className="text-right text-gray-900">{versionLabel || "Initial"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-gray-100 p-5">
|
||||
<p className="text-sm font-semibold text-gray-900">Connected records</p>
|
||||
<dl className="mt-4 space-y-2 text-sm text-gray-600">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Assumptions</dt>
|
||||
<dd className="text-right text-gray-900">{assumptions.filter((row) => row.label.trim()).length}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Scope items</dt>
|
||||
<dd className="text-right text-gray-900">{scopeItems.filter((row) => row.name.trim()).length}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Demand lines</dt>
|
||||
<dd className="text-right text-gray-900">{demandLines.filter((row) => toHours(row.hours) > 0).length}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Resource snapshots</dt>
|
||||
<dd className="text-right text-gray-900">{new Set(demandLines.map((row) => row.resourceId).filter(Boolean)).size}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="border-t border-gray-100 bg-gray-50 px-6 py-6 lg:border-l lg:border-t-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">Dynamic summary</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Project link</p>
|
||||
<p className="mt-1 text-sm text-gray-800">
|
||||
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "No linked project"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Resource-linked demand</p>
|
||||
<p className="mt-1 text-sm text-gray-800">
|
||||
{demandLines.filter((line) => line.resourceId).length} of {demandLines.length} rows tied to live resources
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Calculated totals</p>
|
||||
<p className="mt-1 text-sm text-gray-800">{summary.totalHours.toFixed(1)} h</p>
|
||||
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalCostCents, baseCurrency)} cost</p>
|
||||
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalPriceCents, baseCurrency)} price</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
|
||||
<button type="button" onClick={step === 0 ? onClose : goBack} className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
{step === 0 ? "Cancel" : "Back"}
|
||||
</button>
|
||||
{step < STEP_LABELS.length - 1 ? (
|
||||
<button type="button" onClick={goNext} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<button type="submit" disabled={createMutation.isPending} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{createMutation.isPending ? "Creating..." : "Create Estimate"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type {
|
||||
EstimateDemandLineCalculationMetadata,
|
||||
EstimateDemandLineMetadata,
|
||||
EstimateDemandLineRateMode,
|
||||
} from "@planarchy/shared";
|
||||
|
||||
interface ResourceRateSnapshotLike {
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
function parseRateMode(value: unknown): EstimateDemandLineRateMode | undefined {
|
||||
return value === "resource" || value === "manual" ? value : undefined;
|
||||
}
|
||||
|
||||
export function parseDemandLineMetadata(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): EstimateDemandLineMetadata {
|
||||
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return metadata as EstimateDemandLineMetadata;
|
||||
}
|
||||
|
||||
export function resolveDemandLineCalculationMetadata(options: {
|
||||
resourceSnapshot?: ResourceRateSnapshotLike | null | undefined;
|
||||
metadata?: Record<string, unknown> | null | undefined;
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
}): EstimateDemandLineCalculationMetadata {
|
||||
const resourceSnapshot = options.resourceSnapshot;
|
||||
const parsedMetadata = parseDemandLineMetadata(options.metadata);
|
||||
const calculation =
|
||||
typeof parsedMetadata.calculation === "object" &&
|
||||
parsedMetadata.calculation !== null
|
||||
? parsedMetadata.calculation
|
||||
: undefined;
|
||||
const costRateMode =
|
||||
parseRateMode(calculation?.costRateMode) ??
|
||||
(resourceSnapshot && options.costRateCents === resourceSnapshot.lcrCents
|
||||
? "resource"
|
||||
: "manual");
|
||||
const billRateMode =
|
||||
parseRateMode(calculation?.billRateMode) ??
|
||||
(resourceSnapshot && options.billRateCents === resourceSnapshot.ucrCents
|
||||
? "resource"
|
||||
: "manual");
|
||||
|
||||
return {
|
||||
costRateMode,
|
||||
billRateMode,
|
||||
totalMode: "computed",
|
||||
liveCostRateCents: resourceSnapshot?.lcrCents ?? null,
|
||||
liveBillRateCents: resourceSnapshot?.ucrCents ?? null,
|
||||
liveCurrency: resourceSnapshot?.currency ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDemandLineMetadata(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
calculation: EstimateDemandLineCalculationMetadata,
|
||||
): EstimateDemandLineMetadata {
|
||||
return {
|
||||
...parseDemandLineMetadata(metadata),
|
||||
calculation,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEffectiveDemandLineValues(options: {
|
||||
resourceSnapshot?: ResourceRateSnapshotLike | null | undefined;
|
||||
hours: number;
|
||||
currency?: string | null;
|
||||
defaultCurrency: string;
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
costRateMode: EstimateDemandLineRateMode;
|
||||
billRateMode: EstimateDemandLineRateMode;
|
||||
}) {
|
||||
const effectiveCostRateCents =
|
||||
options.costRateMode === "resource" && options.resourceSnapshot
|
||||
? options.resourceSnapshot.lcrCents
|
||||
: options.costRateCents;
|
||||
const effectiveBillRateCents =
|
||||
options.billRateMode === "resource" && options.resourceSnapshot
|
||||
? options.resourceSnapshot.ucrCents
|
||||
: options.billRateCents;
|
||||
const currency =
|
||||
((options.costRateMode === "resource" || options.billRateMode === "resource") &&
|
||||
options.resourceSnapshot?.currency
|
||||
? options.resourceSnapshot.currency
|
||||
: options.currency) ||
|
||||
options.resourceSnapshot?.currency ||
|
||||
options.defaultCurrency;
|
||||
|
||||
return {
|
||||
effectiveCostRateCents,
|
||||
effectiveBillRateCents,
|
||||
currency,
|
||||
costTotalCents: Math.round(options.hours * effectiveCostRateCents),
|
||||
priceTotalCents: Math.round(options.hours * effectiveBillRateCents),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
EstimateDemandLineMetadata,
|
||||
EstimateExportArtifactPayload,
|
||||
EstimateExportFormat,
|
||||
EstimateStatus,
|
||||
EstimateVersionStatus,
|
||||
} from "@planarchy/shared";
|
||||
|
||||
export interface EstimateMetricView {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
valueDecimal: number;
|
||||
valueCents?: number | null;
|
||||
currency?: string | null;
|
||||
}
|
||||
|
||||
export interface EstimateAssumptionView {
|
||||
id: string;
|
||||
category: string;
|
||||
key: string;
|
||||
label: string;
|
||||
valueType: string;
|
||||
value: unknown;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface EstimateScopeItemView {
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
scopeType: string;
|
||||
packageCode?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
frameCount?: number | null;
|
||||
itemCount?: number | null;
|
||||
unitMode?: string | null;
|
||||
}
|
||||
|
||||
export interface EstimateDemandLineView {
|
||||
id: string;
|
||||
scopeItemId?: string | null;
|
||||
roleId?: string | null;
|
||||
resourceId?: string | null;
|
||||
lineType: string;
|
||||
name: string;
|
||||
chapter?: string | null;
|
||||
rateSource?: string | null;
|
||||
hours: number;
|
||||
currency: string;
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
costTotalCents: number;
|
||||
priceTotalCents: number;
|
||||
monthlySpread?: Record<string, number>;
|
||||
metadata: EstimateDemandLineMetadata;
|
||||
}
|
||||
|
||||
export interface EstimateResourceSnapshotView {
|
||||
id: string;
|
||||
resourceId?: string | null;
|
||||
sourceEid?: string | null;
|
||||
displayName: string;
|
||||
chapter?: string | null;
|
||||
roleId?: string | null;
|
||||
currency: string;
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
fte?: number | null;
|
||||
location?: string | null;
|
||||
country?: string | null;
|
||||
level?: string | null;
|
||||
workType?: string | null;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface EstimateExportView {
|
||||
id: string;
|
||||
fileName: string;
|
||||
format: EstimateExportFormat;
|
||||
payload?: EstimateExportArtifactPayload | Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface EstimateVersionView {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
label?: string | null;
|
||||
status: EstimateVersionStatus;
|
||||
notes?: string | null;
|
||||
lockedAt?: Date | null;
|
||||
updatedAt: Date;
|
||||
assumptions: EstimateAssumptionView[];
|
||||
scopeItems: EstimateScopeItemView[];
|
||||
demandLines: EstimateDemandLineView[];
|
||||
resourceSnapshots: EstimateResourceSnapshotView[];
|
||||
metrics: EstimateMetricView[];
|
||||
exports: EstimateExportView[];
|
||||
}
|
||||
|
||||
export interface EstimateWorkspaceView {
|
||||
id: string;
|
||||
name: string;
|
||||
status: EstimateStatus;
|
||||
projectId?: string | null;
|
||||
opportunityId?: string | null;
|
||||
baseCurrency: string;
|
||||
updatedAt: Date;
|
||||
project?: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
startDate?: string | Date | null;
|
||||
endDate?: string | Date | null;
|
||||
} | null;
|
||||
versions: EstimateVersionView[];
|
||||
}
|
||||
|
||||
export type WorkspaceTab =
|
||||
| "overview"
|
||||
| "assumptions"
|
||||
| "scope"
|
||||
| "staffing"
|
||||
| "financials"
|
||||
| "phasing"
|
||||
| "versions"
|
||||
| "exports";
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,490 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import {
|
||||
compareEstimateVersions,
|
||||
type VersionCompareInput,
|
||||
type ChapterSubtotal,
|
||||
type ResourceSnapshotDiff,
|
||||
type ScopeItemDiff,
|
||||
} from "@planarchy/engine";
|
||||
import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js";
|
||||
|
||||
function formatMoney(cents: number, currency = "EUR") {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function formatDelta(value: number, formatter: (v: number) => string) {
|
||||
const prefix = value > 0 ? "+" : "";
|
||||
return `${prefix}${formatter(value)}`;
|
||||
}
|
||||
|
||||
function formatHoursDelta(delta: number) {
|
||||
const prefix = delta > 0 ? "+" : "";
|
||||
return `${prefix}${delta.toFixed(1)} h`;
|
||||
}
|
||||
|
||||
function versionToInput(v: EstimateVersionView): VersionCompareInput {
|
||||
return {
|
||||
label: v.label ?? null,
|
||||
versionNumber: v.versionNumber,
|
||||
demandLines: v.demandLines.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
hours: l.hours,
|
||||
costRateCents: l.costRateCents,
|
||||
billRateCents: l.billRateCents,
|
||||
costTotalCents: l.costTotalCents,
|
||||
priceTotalCents: l.priceTotalCents,
|
||||
...(l.chapter !== undefined ? { chapter: l.chapter } : {}),
|
||||
lineType: l.lineType,
|
||||
})),
|
||||
assumptions: v.assumptions.map((a) => ({
|
||||
key: a.key,
|
||||
label: a.label,
|
||||
value: a.value,
|
||||
})),
|
||||
scopeItems: v.scopeItems.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
sequenceNo: s.sequenceNo,
|
||||
scopeType: s.scopeType,
|
||||
...(s.packageCode !== undefined ? { packageCode: s.packageCode } : {}),
|
||||
...(s.description !== undefined ? { description: s.description } : {}),
|
||||
...(s.frameCount !== undefined ? { frameCount: s.frameCount } : {}),
|
||||
...(s.itemCount !== undefined ? { itemCount: s.itemCount } : {}),
|
||||
})),
|
||||
resourceSnapshots: v.resourceSnapshots.map((r) => ({
|
||||
id: r.id,
|
||||
...(r.resourceId !== undefined ? { resourceId: r.resourceId } : {}),
|
||||
displayName: r.displayName,
|
||||
...(r.chapter !== undefined ? { chapter: r.chapter } : {}),
|
||||
currency: r.currency,
|
||||
lcrCents: r.lcrCents,
|
||||
ucrCents: r.ucrCents,
|
||||
...(r.location !== undefined ? { location: r.location } : {}),
|
||||
...(r.level !== undefined ? { level: r.level } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_ROW_STYLES = {
|
||||
added: "bg-emerald-50",
|
||||
removed: "bg-red-50",
|
||||
changed: "bg-amber-50",
|
||||
unchanged: "",
|
||||
} as const;
|
||||
|
||||
const STATUS_BADGE_STYLES = {
|
||||
added: "bg-emerald-100 text-emerald-700",
|
||||
removed: "bg-red-100 text-red-700",
|
||||
changed: "bg-amber-100 text-amber-700",
|
||||
unchanged: "bg-gray-100 text-gray-500",
|
||||
} as const;
|
||||
|
||||
export function VersionCompare({ versions }: { versions: EstimateVersionView[] }) {
|
||||
const sorted = useMemo(
|
||||
() => [...versions].sort((a, b) => b.versionNumber - a.versionNumber),
|
||||
[versions],
|
||||
);
|
||||
|
||||
const [aId, setAId] = useState<string>(sorted[1]?.id ?? sorted[0]?.id ?? "");
|
||||
const [bId, setBId] = useState<string>(sorted[0]?.id ?? "");
|
||||
const [hideUnchanged, setHideUnchanged] = useState(false);
|
||||
|
||||
const versionA = sorted.find((v) => v.id === aId);
|
||||
const versionB = sorted.find((v) => v.id === bId);
|
||||
|
||||
const diff = useMemo(() => {
|
||||
if (!versionA || !versionB || versionA.id === versionB.id) return null;
|
||||
return compareEstimateVersions(versionToInput(versionA), versionToInput(versionB));
|
||||
}, [versionA, versionB]);
|
||||
|
||||
if (sorted.length < 2) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-8 text-center text-sm text-gray-500">
|
||||
At least two versions are required to compare.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredDemandDiffs = diff
|
||||
? hideUnchanged
|
||||
? diff.demandLineDiffs.filter((d) => d.status !== "unchanged")
|
||||
: diff.demandLineDiffs
|
||||
: [];
|
||||
|
||||
const filteredAssumptionDiffs = diff
|
||||
? hideUnchanged
|
||||
? diff.assumptionDiffs.filter((d) => d.status !== "unchanged")
|
||||
: diff.assumptionDiffs
|
||||
: [];
|
||||
|
||||
const filteredScopeDiffs = diff
|
||||
? hideUnchanged
|
||||
? diff.scopeItemDiffs.filter((d) => d.status !== "unchanged")
|
||||
: diff.scopeItemDiffs
|
||||
: [];
|
||||
|
||||
const filteredResourceDiffs = diff
|
||||
? hideUnchanged
|
||||
? diff.resourceSnapshotDiffs.filter((d) => d.status !== "unchanged")
|
||||
: diff.resourceSnapshotDiffs
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Version selectors */}
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-4 text-base font-semibold text-gray-900">Compare versions</h3>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Base (A)</span>
|
||||
<select
|
||||
value={aId}
|
||||
onChange={(e) => setAId(e.target.value)}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 focus:border-brand-400 focus:outline-none focus:ring-1 focus:ring-brand-400"
|
||||
>
|
||||
{sorted.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
v{v.versionNumber} {v.label ? `\u2014 ${v.label}` : ""} ({v.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span className="pb-2 text-sm text-gray-400">vs</span>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Compare (B)</span>
|
||||
<select
|
||||
value={bId}
|
||||
onChange={(e) => setBId(e.target.value)}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 focus:border-brand-400 focus:outline-none focus:ring-1 focus:ring-brand-400"
|
||||
>
|
||||
{sorted.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
v{v.versionNumber} {v.label ? `\u2014 ${v.label}` : ""} ({v.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 pb-2 text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideUnchanged}
|
||||
onChange={(e) => setHideUnchanged(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Hide unchanged
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{aId === bId && (
|
||||
<p className="mt-3 text-sm text-amber-600">Select two different versions to compare.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{diff && (
|
||||
<>
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
|
||||
<SummaryCard
|
||||
label="Hours"
|
||||
value={formatHoursDelta(diff.summary.totalHoursDelta)}
|
||||
positive={diff.summary.totalHoursDelta <= 0}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Cost"
|
||||
value={formatDelta(diff.summary.totalCostDelta, (v) => formatMoney(v))}
|
||||
positive={diff.summary.totalCostDelta <= 0}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Price"
|
||||
value={formatDelta(diff.summary.totalPriceDelta, (v) => formatMoney(v))}
|
||||
positive={diff.summary.totalPriceDelta >= 0}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Margin"
|
||||
value={`${diff.summary.marginPercentB.toFixed(1)}% (${diff.summary.marginPercentDelta >= 0 ? "+" : ""}${diff.summary.marginPercentDelta.toFixed(1)}pp)`}
|
||||
positive={diff.summary.marginPercentDelta >= 0}
|
||||
/>
|
||||
<SummaryCard label="Lines +" value={`+${diff.summary.linesAdded}`} positive />
|
||||
<SummaryCard label="Lines -" value={`-${diff.summary.linesRemoved}`} positive={diff.summary.linesRemoved === 0} />
|
||||
<SummaryCard label="Lines ~" value={String(diff.summary.linesChanged)} positive={diff.summary.linesChanged === 0} />
|
||||
<SummaryCard label="Resources ~" value={String(diff.summary.resourceSnapshotsChanged)} positive={diff.summary.resourceSnapshotsChanged === 0} />
|
||||
</div>
|
||||
|
||||
{/* Demand line diffs */}
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Demand lines</h3>
|
||||
{filteredDemandDiffs.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-gray-400">
|
||||
{hideUnchanged ? "No changes in demand lines." : "No demand lines to compare."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours delta</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost delta</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Price (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Price (B)</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Price delta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredDemandDiffs.map((d, i) => (
|
||||
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
||||
{d.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.hours.toFixed(1) ?? "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.hours.toFixed(1) ?? "\u2014"}</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.hoursDelta))}>
|
||||
{d.hoursDelta != null ? formatHoursDelta(d.hoursDelta) : "\u2014"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{d.a ? formatMoney(d.a.costTotalCents) : "\u2014"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{d.b ? formatMoney(d.b.costTotalCents) : "\u2014"}
|
||||
</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.costDelta))}>
|
||||
{d.costDelta != null ? formatDelta(d.costDelta, (v) => formatMoney(v)) : "\u2014"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{d.a ? formatMoney(d.a.priceTotalCents) : "\u2014"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{d.b ? formatMoney(d.b.priceTotalCents) : "\u2014"}
|
||||
</td>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(d.priceDelta))}>
|
||||
{d.priceDelta != null ? formatDelta(d.priceDelta, (v) => formatMoney(v)) : "\u2014"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assumption diffs */}
|
||||
{(filteredAssumptionDiffs.length > 0 || !hideUnchanged) && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Assumptions</h3>
|
||||
{filteredAssumptionDiffs.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-gray-400">
|
||||
{hideUnchanged ? "No changes in assumptions." : "No assumptions to compare."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Assumption</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Value (A)</th>
|
||||
<th className="pl-3 py-2 font-medium">Value (B)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAssumptionDiffs.map((d) => (
|
||||
<tr key={d.key} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{d.label}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
||||
{d.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700">{formatAssumptionValue(d.aValue)}</td>
|
||||
<td className="pl-3 py-2 text-gray-700">{formatAssumptionValue(d.bValue)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapter subtotals */}
|
||||
{diff.chapterSubtotals.length > 0 && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">By chapter</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Chapter</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours delta</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost (B)</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Cost delta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diff.chapterSubtotals.map((ch) => (
|
||||
<tr key={ch.chapter} className={clsx("border-b border-gray-100", ch.costDelta !== 0 ? "bg-amber-50" : "")}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{ch.chapter}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursA.toFixed(1)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursB.toFixed(1)}</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(ch.hoursDelta))}>
|
||||
{formatHoursDelta(ch.hoursDelta)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costA)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costB)}</td>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(ch.costDelta))}>
|
||||
{formatDelta(ch.costDelta, (v) => formatMoney(v))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scope item diffs */}
|
||||
{filteredScopeDiffs.length > 0 && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">
|
||||
Scope items
|
||||
{(diff.summary.scopeItemsAdded > 0 || diff.summary.scopeItemsRemoved > 0 || diff.summary.scopeItemsChanged > 0) && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||
{diff.summary.scopeItemsAdded > 0 && <span className="text-emerald-600">+{diff.summary.scopeItemsAdded}</span>}
|
||||
{diff.summary.scopeItemsRemoved > 0 && <span className="ml-2 text-red-600">-{diff.summary.scopeItemsRemoved}</span>}
|
||||
{diff.summary.scopeItemsChanged > 0 && <span className="ml-2 text-amber-600">~{diff.summary.scopeItemsChanged}</span>}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Type</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Frames (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Frames (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Items (A)</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Items (B)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredScopeDiffs.map((d, i) => (
|
||||
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{d.scopeType}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
||||
{d.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.frameCount ?? "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.frameCount ?? "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.itemCount ?? "\u2014"}</td>
|
||||
<td className="pl-3 py-2 text-right tabular-nums text-gray-700">{d.b?.itemCount ?? "\u2014"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resource snapshot diffs */}
|
||||
{filteredResourceDiffs.length > 0 && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Resource rates</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-right font-medium">LCR (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">LCR (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">UCR (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">UCR (B)</th>
|
||||
<th className="px-3 py-2 font-medium">Location (A)</th>
|
||||
<th className="pl-3 py-2 font-medium">Location (B)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredResourceDiffs.map((d, i) => (
|
||||
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{d.displayName}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
||||
{d.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.lcrCents) : "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.lcrCents) : "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.ucrCents) : "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.ucrCents) : "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{d.a?.location ?? "\u2014"}</td>
|
||||
<td className="pl-3 py-2 text-gray-600">{d.b?.location ?? "\u2014"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
positive,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
positive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-4 text-center shadow-sm">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
|
||||
<p className={clsx("mt-1 text-lg font-semibold tabular-nums", positive ? "text-emerald-700" : "text-red-700")}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function deltaColor(delta: number | undefined): string {
|
||||
if (delta == null || delta === 0) return "text-gray-400";
|
||||
return delta > 0 ? "text-red-600" : "text-emerald-600";
|
||||
}
|
||||
|
||||
function formatAssumptionValue(value: unknown): string {
|
||||
if (value === undefined) return "\u2014";
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface WeeklyPhasingViewProps {
|
||||
estimateId: string;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
type ViewMode = "by_line" | "by_chapter";
|
||||
type PhasingPattern = "even" | "front_loaded" | "back_loaded";
|
||||
|
||||
function getDefaultDateRange(): { start: string; end: string } {
|
||||
const now = new Date();
|
||||
const start = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0);
|
||||
const end = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, "0")}-${String(endDate.getDate()).padStart(2, "0")}`;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function heatColor(hours: number, maxHours: number): string {
|
||||
if (hours === 0 || maxHours === 0) return "";
|
||||
const ratio = Math.min(hours / maxHours, 1);
|
||||
if (ratio < 0.25) return "bg-blue-50";
|
||||
if (ratio < 0.5) return "bg-blue-100";
|
||||
if (ratio < 0.75) return "bg-blue-200";
|
||||
return "bg-blue-300";
|
||||
}
|
||||
|
||||
export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProps) {
|
||||
const defaults = getDefaultDateRange();
|
||||
const [startDate, setStartDate] = useState(defaults.start);
|
||||
const [endDate, setEndDate] = useState(defaults.end);
|
||||
const [pattern, setPattern] = useState<PhasingPattern>("even");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("by_line");
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const phasingQuery = trpc.estimate.getWeeklyPhasing.useQuery(
|
||||
{ estimateId },
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const generateMutation = trpc.estimate.generateWeeklyPhasing.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.estimate.getWeeklyPhasing.invalidate({ estimateId });
|
||||
void utils.estimate.getById.invalidate({ id: estimateId });
|
||||
},
|
||||
});
|
||||
|
||||
const data = phasingQuery.data;
|
||||
|
||||
// Compute max hours for heat-map coloring
|
||||
const maxHours = useMemo(() => {
|
||||
if (!data?.hasPhasing) return 0;
|
||||
let max = 0;
|
||||
for (const line of data.lines) {
|
||||
for (const h of Object.values(line.weeklyHours)) {
|
||||
if (h > max) max = h;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}, [data]);
|
||||
|
||||
// Compute column totals
|
||||
const columnTotals = useMemo(() => {
|
||||
if (!data?.hasPhasing) return {};
|
||||
const totals: Record<string, number> = {};
|
||||
for (const line of data.lines) {
|
||||
for (const [weekKey, hours] of Object.entries(line.weeklyHours)) {
|
||||
totals[weekKey] = Math.round(((totals[weekKey] ?? 0) + hours) * 100) / 100;
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
}, [data]);
|
||||
|
||||
// Compute chapter column totals
|
||||
const chapterColumnTotals = useMemo(() => {
|
||||
if (!data?.hasPhasing) return {};
|
||||
const totals: Record<string, number> = {};
|
||||
for (const chapterHours of Object.values(data.chapterAggregation)) {
|
||||
for (const [weekKey, hours] of Object.entries(chapterHours)) {
|
||||
totals[weekKey] = Math.round(((totals[weekKey] ?? 0) + hours) * 100) / 100;
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
}, [data]);
|
||||
|
||||
// Compute max hours for chapter view
|
||||
const maxChapterHours = useMemo(() => {
|
||||
if (!data?.hasPhasing) return 0;
|
||||
let max = 0;
|
||||
for (const chapterHours of Object.values(data.chapterAggregation)) {
|
||||
for (const h of Object.values(chapterHours)) {
|
||||
if (h > max) max = h;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}, [data]);
|
||||
|
||||
const handleGenerate = () => {
|
||||
generateMutation.mutate({
|
||||
estimateId,
|
||||
startDate,
|
||||
endDate,
|
||||
pattern,
|
||||
});
|
||||
};
|
||||
|
||||
// Use config dates from existing phasing if available
|
||||
const effectiveStart = data?.hasPhasing ? data.config.startDate : startDate;
|
||||
const effectiveEnd = data?.hasPhasing ? data.config.endDate : endDate;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header / Controls */}
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">
|
||||
Weekly Phasing (4Dispo)
|
||||
</h3>
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={data?.hasPhasing ? effectiveStart : startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={data?.hasPhasing ? effectiveEnd : endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Pattern
|
||||
</label>
|
||||
<select
|
||||
value={pattern}
|
||||
onChange={(e) => setPattern(e.target.value as PhasingPattern)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="even">Even Distribution</option>
|
||||
<option value="front_loaded">Front Loaded (60/40)</option>
|
||||
<option value="back_loaded">Back Loaded (40/60)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={generateMutation.isPending}
|
||||
className={clsx(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium text-white",
|
||||
generateMutation.isPending
|
||||
? "cursor-not-allowed bg-gray-400"
|
||||
: "bg-sky-600 hover:bg-sky-700",
|
||||
)}
|
||||
>
|
||||
{generateMutation.isPending ? "Generating..." : "Generate Phasing"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generateMutation.isError && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{generateMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{generateMutation.isSuccess && (
|
||||
<p className="mt-2 text-sm text-emerald-600">
|
||||
Phasing generated for {generateMutation.data.linesUpdated} demand
|
||||
lines.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
{data?.hasPhasing && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("by_line")}
|
||||
className={clsx(
|
||||
"rounded-lg px-3 py-1.5 text-sm font-medium",
|
||||
viewMode === "by_line"
|
||||
? "bg-sky-100 text-sky-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
)}
|
||||
>
|
||||
By Line
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("by_chapter")}
|
||||
className={clsx(
|
||||
"rounded-lg px-3 py-1.5 text-sm font-medium",
|
||||
viewMode === "by_chapter"
|
||||
? "bg-sky-100 text-sky-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
)}
|
||||
>
|
||||
By Chapter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phasing Grid */}
|
||||
{phasingQuery.isLoading && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
|
||||
Loading phasing data...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !data.hasPhasing && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
|
||||
No weekly phasing generated yet. Use the controls above to generate a
|
||||
phasing distribution.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.hasPhasing && viewMode === "by_line" && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
|
||||
Demand Line
|
||||
</th>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
return (
|
||||
<th
|
||||
key={key}
|
||||
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
|
||||
>
|
||||
{week.label}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
|
||||
Total
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.lines.map((line) => {
|
||||
const lineTotal = Object.values(line.weeklyHours).reduce(
|
||||
(sum, h) => sum + h,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<tr
|
||||
key={line.id}
|
||||
className="border-b border-gray-100 hover:bg-gray-50/50"
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
|
||||
<div className="truncate max-w-[200px]" title={line.name}>
|
||||
{line.name}
|
||||
</div>
|
||||
{line.chapter && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{line.chapter}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
const hours = line.weeklyHours[key] ?? 0;
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className={clsx(
|
||||
"px-3 py-2 text-right tabular-nums",
|
||||
heatColor(hours, maxHours),
|
||||
)}
|
||||
>
|
||||
{hours > 0 ? hours.toFixed(1) : "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
|
||||
{lineTotal.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||||
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
|
||||
Total
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
const total = columnTotals[key] ?? 0;
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className="px-3 py-3 text-right tabular-nums text-gray-900"
|
||||
>
|
||||
{total > 0 ? total.toFixed(1) : "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
|
||||
{Object.values(columnTotals)
|
||||
.reduce((sum, h) => sum + h, 0)
|
||||
.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.hasPhasing && viewMode === "by_chapter" && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
|
||||
Chapter
|
||||
</th>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
return (
|
||||
<th
|
||||
key={key}
|
||||
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
|
||||
>
|
||||
{week.label}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
|
||||
Total
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(data.chapterAggregation)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([chapter, weeklyHours]) => {
|
||||
const chapterTotal = Object.values(weeklyHours).reduce(
|
||||
(sum, h) => sum + h,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<tr
|
||||
key={chapter}
|
||||
className="border-b border-gray-100 hover:bg-gray-50/50"
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
|
||||
{chapter}
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
const hours = weeklyHours[key] ?? 0;
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className={clsx(
|
||||
"px-3 py-2 text-right tabular-nums",
|
||||
heatColor(hours, maxChapterHours),
|
||||
)}
|
||||
>
|
||||
{hours > 0 ? hours.toFixed(1) : "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
|
||||
{chapterTotal.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||||
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
|
||||
Total
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
const total = chapterColumnTotals[key] ?? 0;
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className="px-3 py-3 text-right tabular-nums text-gray-900"
|
||||
>
|
||||
{total > 0 ? total.toFixed(1) : "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
|
||||
{Object.values(chapterColumnTotals)
|
||||
.reduce((sum, h) => sum + h, 0)
|
||||
.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info about current phasing config */}
|
||||
{data?.hasPhasing && data.config && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">Current phasing:</span>{" "}
|
||||
{data.config.pattern.replace("_", " ")} distribution from{" "}
|
||||
{data.config.startDate} to {data.config.endDate} across{" "}
|
||||
{data.weeks.length} weeks, {data.lines.length} demand lines.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import { Suspense, useState } from "react";
|
||||
import { PreferencesModal } from "./PreferencesModal.js";
|
||||
import { ThemeProvider } from "./ThemeProvider.js";
|
||||
import { NotificationBell } from "../notifications/NotificationBell.js";
|
||||
import { NavProgressBar } from "~/components/ui/NavProgressBar.js";
|
||||
|
||||
const allNavItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: "📊", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
{ href: "/resources", label: "Resources", icon: "👥", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/projects", label: "Projects", icon: "📋", roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
|
||||
{ href: "/estimates", label: "Estimates", icon: "🧮", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
{ href: "/allocations", label: "Allocations", icon: "📅", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/timeline", label: "Timeline", icon: "🗓️", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
{ href: "/staffing", label: "Staffing", icon: "🎯", roles: ["ADMIN", "MANAGER"] },
|
||||
{ href: "/vacations", label: "Vacations", icon: "🏖️", roles: ["ADMIN", "MANAGER"] },
|
||||
{ href: "/vacations/my", label: "My Vacations", icon: "🌴", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
{ href: "/roles", label: "Roles", icon: "🏷️", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/analytics/skills", label: "Skills Analytics", icon: "📈", roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
|
||||
{ href: "/reports/chargeability", label: "Chargeability", icon: "📊", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
];
|
||||
|
||||
const adminNavItems = [
|
||||
{ href: "/admin/blueprints", label: "Blueprints", icon: "🏗️" },
|
||||
{ href: "/admin/countries", label: "Countries", icon: "🌍" },
|
||||
{ href: "/admin/org-units", label: "Org Units", icon: "🏢" },
|
||||
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: "📊" },
|
||||
{ href: "/admin/clients", label: "Clients", icon: "🏦" },
|
||||
{ href: "/admin/rate-cards", label: "Rate Cards", icon: "💲" },
|
||||
{ href: "/admin/effort-rules", label: "Effort Rules", icon: "📐" },
|
||||
{ href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: "📈" },
|
||||
{ href: "/admin/management-levels", label: "Mgmt Levels", icon: "📶" },
|
||||
{ href: "/admin/users", label: "Users", icon: "👤" },
|
||||
{ href: "/admin/settings", label: "Settings", icon: "⚙️" },
|
||||
{ href: "/admin/skill-import", label: "Skill Import", icon: "📥" },
|
||||
];
|
||||
|
||||
const managerNavItems = [
|
||||
{ href: "/admin/vacations", label: "Vacation Mgmt", icon: "🏖️" },
|
||||
];
|
||||
|
||||
function Sidebar({ userRole }: { userRole: string }) {
|
||||
const pathname = usePathname();
|
||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||
|
||||
const visibleNavItems = allNavItems.filter((item) => item.roles.includes(userRole));
|
||||
const showAdmin = userRole === "ADMIN";
|
||||
const showManagerSection = userRole === "ADMIN" || userRole === "MANAGER";
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0">
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-50">
|
||||
Pl<span className="text-brand-600">anarchy</span>
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">Resource Planning</p>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<div className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||
{visibleNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as Route}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
pathname.startsWith(item.href)
|
||||
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
|
||||
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{showManagerSection && (
|
||||
<>
|
||||
<div className="pt-3 pb-1">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<span className="px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{showAdmin ? "Admin" : "Management"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{showAdmin && adminNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as Route}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
pathname.startsWith(item.href)
|
||||
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
|
||||
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{managerNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as Route}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
pathname.startsWith(item.href)
|
||||
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
|
||||
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom actions */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-800 space-y-1">
|
||||
<div className="flex items-center gap-2 px-3 py-1">
|
||||
<NotificationBell />
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">Notifications</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPrefsOpen(true)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Preferences
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut({ callbackUrl: "/auth/signin" })}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{prefsOpen && <PreferencesModal onClose={() => setPrefsOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children, userRole = "USER" }: { children: React.ReactNode; userRole?: string }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Suspense>
|
||||
<NavProgressBar />
|
||||
</Suspense>
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<Sidebar userRole={userRole} />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "~/hooks/useTheme.js";
|
||||
import type { AccentColor, ThemeMode } from "~/hooks/useTheme.js";
|
||||
import { useAppPreferences, type HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
interface PreferencesModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ACCENT_OPTIONS: { value: AccentColor; label: string; swatch: string }[] = [
|
||||
{ value: "sky", label: "Sky", swatch: "#0284c7" },
|
||||
{ value: "indigo", label: "Indigo", swatch: "#4f46e5" },
|
||||
{ value: "violet", label: "Violet", swatch: "#7c3aed" },
|
||||
{ value: "emerald", label: "Emerald", swatch: "#059669" },
|
||||
{ value: "rose", label: "Rose", swatch: "#e11d48" },
|
||||
{ value: "amber", label: "Amber", swatch: "#d97706" },
|
||||
];
|
||||
|
||||
export function PreferencesModal({ onClose }: PreferencesModalProps) {
|
||||
const { prefs, setMode, setAccent } = useTheme();
|
||||
const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme } = useAppPreferences();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-end sm:items-center justify-center p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Preferences</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-6">
|
||||
{/* Theme mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Appearance
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(["light", "dark"] as ThemeMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setMode(mode)}
|
||||
className={clsx(
|
||||
"flex items-center justify-center gap-2 px-4 py-3 rounded-xl border-2 text-sm font-medium transition-all",
|
||||
prefs.mode === mode
|
||||
? "border-brand-600 bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
|
||||
)}
|
||||
>
|
||||
{mode === "light" ? (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
Light
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
Dark
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accent color */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Highlight Color
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{ACCENT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setAccent(opt.value)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-2.5 rounded-xl border-2 text-xs font-medium transition-all",
|
||||
prefs.accent === opt.value
|
||||
? "border-brand-600 bg-brand-50 dark:bg-gray-700"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="w-4 h-4 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor: opt.swatch,
|
||||
boxShadow: prefs.accent === opt.value ? `0 0 0 2px white, 0 0 0 4px ${opt.swatch}` : "none",
|
||||
}}
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">{opt.label}</span>
|
||||
{prefs.accent === opt.value && (
|
||||
<svg className="w-3 h-3 ml-auto text-brand-600 shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fillRule="evenodd" d="M20.707 5.293a1 1 0 010 1.414l-11 11a1 1 0 01-1.414 0l-5-5a1 1 0 011.414-1.414L9 15.586 19.293 5.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline defaults */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Timeline
|
||||
</label>
|
||||
|
||||
{/* Display mode */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Row display style</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
{ value: "strip", label: "Strips", desc: "Classic Gantt blocks" },
|
||||
{ value: "bar", label: "Bars", desc: "Daily stacked hours" },
|
||||
{ value: "heatmap", label: "Heatmap", desc: "Utilisation colours" },
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setTimelineDisplayMode(opt.value)}
|
||||
className={clsx(
|
||||
"flex flex-col items-center gap-1 px-3 py-2.5 rounded-xl border-2 text-xs font-medium transition-all",
|
||||
appPrefs.timelineDisplayMode === opt.value
|
||||
? "border-brand-600 bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
|
||||
)}
|
||||
>
|
||||
{/* Miniature icon */}
|
||||
{opt.value === "strip" ? (
|
||||
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
|
||||
<rect x="2" y="4" width="12" height="8" rx="2" fill="currentColor" opacity="0.6" />
|
||||
<rect x="18" y="4" width="12" height="8" rx="2" fill="currentColor" opacity="0.4" />
|
||||
</svg>
|
||||
) : opt.value === "bar" ? (
|
||||
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
|
||||
<rect x="2" y="8" width="5" height="8" rx="1" fill="currentColor" opacity="0.6" />
|
||||
<rect x="9" y="4" width="5" height="12" rx="1" fill="currentColor" opacity="0.5" />
|
||||
<rect x="16" y="6" width="5" height="10" rx="1" fill="currentColor" opacity="0.7" />
|
||||
<rect x="23" y="2" width="5" height="14" rx="1" fill="currentColor" opacity="0.4" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
|
||||
<rect x="0" y="0" width="8" height="16" fill="#22c55e" opacity="0.5" />
|
||||
<rect x="8" y="0" width="8" height="16" fill="#eab308" opacity="0.5" />
|
||||
<rect x="16" y="0" width="8" height="16" fill="#f97316" opacity="0.5" />
|
||||
<rect x="24" y="0" width="8" height="16" fill="#ef4444" opacity="0.6" />
|
||||
<rect x="2" y="4" width="10" height="8" rx="1" fill="white" opacity="0.7" />
|
||||
<rect x="18" y="4" width="10" height="8" rx="1" fill="white" opacity="0.5" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{opt.label}</span>
|
||||
<span className="font-normal text-[10px] opacity-70">{opt.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Heatmap color scheme */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Heatmap color scale</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{
|
||||
value: "green-red" as HeatmapColorScheme,
|
||||
label: "Green → Red",
|
||||
stops: ["#22c55e","#84cc16","#facc15","#f97316","#ef4444"],
|
||||
},
|
||||
{
|
||||
value: "blue-orange" as HeatmapColorScheme,
|
||||
label: "Blue → Orange",
|
||||
stops: ["#38bdf8","#3b82f6","#fbbf24","#f97316","#ef4444"],
|
||||
},
|
||||
{
|
||||
value: "purple-yellow" as HeatmapColorScheme,
|
||||
label: "Purple → Yellow",
|
||||
stops: ["#a78bfa","#8b5cf6","#facc15","#f59e0b","#ef4444"],
|
||||
},
|
||||
{
|
||||
value: "mono" as HeatmapColorScheme,
|
||||
label: "Monochrome",
|
||||
stops: ["#9ca3af","#6b7280","#4b5563","#374151","#111827"],
|
||||
},
|
||||
]).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setHeatmapColorScheme(opt.value)}
|
||||
className={clsx(
|
||||
"flex flex-col gap-1.5 px-2.5 py-2 rounded-xl border-2 text-xs font-medium transition-all text-left",
|
||||
appPrefs.heatmapColorScheme === opt.value
|
||||
? "border-brand-600 bg-brand-50 dark:bg-brand-900/30"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
{/* Gradient swatch */}
|
||||
<div className="flex rounded overflow-hidden w-full h-3">
|
||||
{opt.stops.map((c) => (
|
||||
<div key={c} className="flex-1" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-gray-700 dark:text-gray-300">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<div className="relative mt-0.5 flex-shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appPrefs.hideCompletedProjects}
|
||||
onChange={(e) => setHideCompletedProjects(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className={clsx(
|
||||
"w-9 h-5 rounded-full transition-colors",
|
||||
appPrefs.hideCompletedProjects ? "bg-brand-600" : "bg-gray-200 dark:bg-gray-700",
|
||||
)} />
|
||||
<div className={clsx(
|
||||
"absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform",
|
||||
appPrefs.hideCompletedProjects ? "translate-x-4" : "translate-x-0",
|
||||
)} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-800 dark:text-gray-200 font-medium leading-tight block">
|
||||
Hide completed & cancelled projects
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Can be overridden per session in the timeline filter panel.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Preview note */}
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Changes apply instantly and are saved in your browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Applies the stored theme to <html> immediately on mount (client only).
|
||||
* Must be rendered inside the layout BEFORE the page content.
|
||||
*/
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem("planarchy_theme");
|
||||
if (!raw) return;
|
||||
const prefs = JSON.parse(raw) as { mode?: string; accent?: string };
|
||||
const html = document.documentElement;
|
||||
if (prefs.mode === "dark") html.classList.add("dark");
|
||||
else html.classList.remove("dark");
|
||||
if (prefs.accent) html.setAttribute("data-accent", prefs.accent);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
function relativeTime(date: Date): string {
|
||||
const now = Date.now();
|
||||
const diff = now - date.getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}mo ago`;
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: notifications = [] } = trpc.notification.list.useQuery(
|
||||
{ limit: 20 },
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
const markRead = trpc.notification.markRead.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.notification.unreadCount.invalidate();
|
||||
void utils.notification.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
function handleMarkAllRead() {
|
||||
markRead.mutate({});
|
||||
}
|
||||
|
||||
function handleMarkOne(id: string) {
|
||||
markRead.mutate({ id });
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
{/* Bell button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="relative p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-red-500 rounded-full leading-none">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown panel */}
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-50">
|
||||
Notifications
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={markRead.isPending}
|
||||
className="text-xs text-brand-600 dark:text-brand-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="max-h-96 overflow-y-auto divide-y divide-gray-50 dark:divide-gray-800">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
No notifications yet
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n) => {
|
||||
const isUnread = n.readAt === null;
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isUnread) handleMarkOne(n.id);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
|
||||
isUnread ? "bg-blue-50/60 dark:bg-blue-900/10" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{isUnread && (
|
||||
<span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
|
||||
)}
|
||||
<div className={isUnread ? "" : "ml-4"}>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug">
|
||||
{n.title}
|
||||
</p>
|
||||
{n.body && (
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{n.body}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{relativeTime(new Date(n.createdAt))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
|
||||
interface Warning {
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface BudgetStatusBarProps {
|
||||
budgetCents: number;
|
||||
allocatedCents: number;
|
||||
confirmedCents: number;
|
||||
proposedCents: number;
|
||||
warnings: Warning[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatEur(cents: number): string {
|
||||
return (cents / 100).toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function getConfirmedBarColor(utilizationPercent: number): string {
|
||||
if (utilizationPercent > 95) return "bg-red-600";
|
||||
if (utilizationPercent > 85) return "bg-orange-600";
|
||||
if (utilizationPercent > 70) return "bg-yellow-600";
|
||||
return "bg-green-600";
|
||||
}
|
||||
|
||||
function getProposedBarColor(utilizationPercent: number): string {
|
||||
if (utilizationPercent > 95) return "bg-red-300";
|
||||
if (utilizationPercent > 85) return "bg-orange-300";
|
||||
if (utilizationPercent > 70) return "bg-yellow-300";
|
||||
return "bg-green-300";
|
||||
}
|
||||
|
||||
function getWarningBadgeStyle(level: string): string {
|
||||
if (level === "critical") return "bg-red-100 text-red-700 border border-red-200";
|
||||
if (level === "warning") return "bg-orange-100 text-orange-700 border border-orange-200";
|
||||
return "bg-yellow-100 text-yellow-700 border border-yellow-200";
|
||||
}
|
||||
|
||||
export function BudgetStatusBar({
|
||||
budgetCents,
|
||||
allocatedCents,
|
||||
confirmedCents,
|
||||
proposedCents,
|
||||
warnings,
|
||||
className,
|
||||
}: BudgetStatusBarProps) {
|
||||
const utilizationPercent = budgetCents > 0 ? (allocatedCents / budgetCents) * 100 : 0;
|
||||
const confirmedPercent = budgetCents > 0 ? (confirmedCents / budgetCents) * 100 : 0;
|
||||
const proposedPercent = budgetCents > 0 ? (proposedCents / budgetCents) * 100 : 0;
|
||||
const remainingCents = budgetCents - allocatedCents;
|
||||
|
||||
// Cap visual bar segments at 100% total
|
||||
const cappedConfirmedPercent = Math.min(confirmedPercent, 100);
|
||||
const cappedProposedPercent = Math.min(proposedPercent, Math.max(0, 100 - cappedConfirmedPercent));
|
||||
|
||||
const highestWarning = warnings.length > 0
|
||||
? warnings.reduce((prev, curr) => {
|
||||
const levels: Record<string, number> = { info: 0, warning: 1, critical: 2 };
|
||||
return (levels[curr.level] ?? 0) > (levels[prev.level] ?? 0) ? curr : prev;
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={clsx("space-y-1.5", className)}>
|
||||
{/* Progress bar with stacked segments */}
|
||||
<div className="relative h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
{/* Confirmed segment */}
|
||||
<div
|
||||
className={clsx("absolute left-0 top-0 h-full transition-all", getConfirmedBarColor(utilizationPercent))}
|
||||
style={{ width: `${cappedConfirmedPercent}%` }}
|
||||
/>
|
||||
{/* Proposed segment */}
|
||||
<div
|
||||
className={clsx("absolute top-0 h-full transition-all", getProposedBarColor(utilizationPercent))}
|
||||
style={{ left: `${cappedConfirmedPercent}%`, width: `${cappedProposedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Labels row */}
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span>
|
||||
<span className="font-medium">{formatEur(allocatedCents)}</span>
|
||||
{" / "}
|
||||
<span>{formatEur(budgetCents)}</span>
|
||||
{" "}
|
||||
<span className="text-gray-400">({utilizationPercent.toFixed(1)}%)</span>
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{highestWarning && (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium",
|
||||
getWarningBadgeStyle(highestWarning.level),
|
||||
)}
|
||||
>
|
||||
{highestWarning.level === "critical" ? "⚠" : highestWarning.level === "warning" ? "!" : "i"}
|
||||
{warnings.length > 1 ? `${warnings.length} warnings` : "Warning"}
|
||||
</span>
|
||||
)}
|
||||
<span className={clsx("font-medium", remainingCents < 0 ? "text-red-600" : "text-gray-700")}>
|
||||
{remainingCents >= 0 ? `${formatEur(remainingCents)} left` : `${formatEur(Math.abs(remainingCents))} over`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={clsx("inline-block w-2.5 h-2.5 rounded-sm", getConfirmedBarColor(utilizationPercent))} />
|
||||
Confirmed {formatEur(confirmedCents)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={clsx("inline-block w-2.5 h-2.5 rounded-sm", getProposedBarColor(utilizationPercent))} />
|
||||
Proposed {formatEur(proposedCents)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { BudgetStatusBar } from "./BudgetStatusBar.js";
|
||||
|
||||
interface BudgetStatusCardProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function formatEur(cents: number): string {
|
||||
return (cents / 100).toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function WarningIcon({ level }: { level: string }) {
|
||||
if (level === "critical") {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (level === "warning") {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-orange-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-4 h-4 text-blue-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function getWarningRowStyle(level: string): string {
|
||||
if (level === "critical") return "bg-red-50 dark:bg-red-900/30 border border-red-100 dark:border-red-800 text-red-800 dark:text-red-400";
|
||||
if (level === "warning") return "bg-orange-50 dark:bg-orange-900/30 border border-orange-100 dark:border-orange-800 text-orange-800 dark:text-orange-400";
|
||||
return "bg-blue-50 dark:bg-blue-900/30 border border-blue-100 dark:border-blue-800 text-blue-800 dark:text-blue-400";
|
||||
}
|
||||
|
||||
export function BudgetStatusCard({ projectId }: BudgetStatusCardProps) {
|
||||
const { data, isLoading, error } = trpc.timeline.getBudgetStatus.useQuery({ projectId });
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-4" />
|
||||
<div className="h-3 bg-gray-100 dark:bg-gray-700 rounded-full mb-3" />
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-14 bg-gray-100 dark:bg-gray-700 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-red-200 dark:border-red-800 p-6">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">Failed to load budget status: {error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const {
|
||||
budgetCents,
|
||||
allocatedCents,
|
||||
confirmedCents,
|
||||
proposedCents,
|
||||
remainingCents,
|
||||
winProbabilityWeightedCents,
|
||||
warnings,
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">Budget Status</h3>
|
||||
|
||||
{/* Progress bar */}
|
||||
<BudgetStatusBar
|
||||
budgetCents={budgetCents}
|
||||
allocatedCents={allocatedCents}
|
||||
confirmedCents={confirmedCents}
|
||||
proposedCents={proposedCents}
|
||||
warnings={warnings}
|
||||
/>
|
||||
|
||||
{/* Numeric details grid */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Total Budget</p>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{formatEur(budgetCents)}</p>
|
||||
</div>
|
||||
<div className="bg-green-50 dark:bg-green-900/30 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Confirmed</p>
|
||||
<p className="text-sm font-semibold text-green-800 dark:text-green-400">{formatEur(confirmedCents)}</p>
|
||||
</div>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/30 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Proposed</p>
|
||||
<p className="text-sm font-semibold text-yellow-800 dark:text-yellow-400">{formatEur(proposedCents)}</p>
|
||||
</div>
|
||||
<div className={clsx("rounded-lg p-3", remainingCents < 0 ? "bg-red-50 dark:bg-red-900/30" : "bg-blue-50 dark:bg-blue-900/30")}>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Remaining</p>
|
||||
<p className={clsx("text-sm font-semibold", remainingCents < 0 ? "text-red-800 dark:text-red-400" : "text-blue-800 dark:text-blue-400")}>
|
||||
{formatEur(remainingCents)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Win-probability weighted amount */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 border-t border-gray-100 dark:border-gray-700 pt-3">
|
||||
<span className="text-gray-400 dark:text-gray-500">Win-probability weighted cost:</span>
|
||||
<span className="font-medium text-gray-800 dark:text-gray-100">{formatEur(winProbabilityWeightedCents)}</span>
|
||||
</div>
|
||||
|
||||
{/* Warnings list */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="space-y-2 border-t border-gray-100 dark:border-gray-700 pt-3">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Warnings</p>
|
||||
{warnings.map((warning, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={clsx(
|
||||
"flex items-start gap-2 rounded-lg px-3 py-2 text-sm",
|
||||
getWarningRowStyle(warning.level),
|
||||
)}
|
||||
>
|
||||
<WarningIcon level={warning.level} />
|
||||
<span>{warning.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import { OrderType, AllocationType, ProjectStatus } from "@planarchy/shared";
|
||||
import type { Project } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
|
||||
const ORDER_TYPE_OPTIONS = [
|
||||
{ value: "BD", label: "BD" },
|
||||
{ value: "CHARGEABLE", label: "Chargeable" },
|
||||
{ value: "INTERNAL", label: "Internal" },
|
||||
{ value: "OVERHEAD", label: "Overhead" },
|
||||
] as const;
|
||||
|
||||
const ALLOCATION_TYPE_OPTIONS = [
|
||||
{ value: "INT", label: "INT" },
|
||||
{ value: "EXT", label: "EXT" },
|
||||
] as const;
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "ACTIVE", label: "Active" },
|
||||
{ value: "ON_HOLD", label: "On Hold" },
|
||||
{ value: "COMPLETED", label: "Completed" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
] as const;
|
||||
|
||||
function formatDateForInput(date: Date | string): string {
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
shortCode: string;
|
||||
name: string;
|
||||
orderType: string;
|
||||
allocationType: string;
|
||||
winProbability: string;
|
||||
budgetEur: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: string;
|
||||
responsiblePerson: string;
|
||||
utilizationCategoryId: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
function getDefaultForm(): FormState {
|
||||
const today = formatDateForInput(new Date());
|
||||
return {
|
||||
shortCode: "",
|
||||
name: "",
|
||||
orderType: "CHARGEABLE",
|
||||
allocationType: "INT",
|
||||
winProbability: "100",
|
||||
budgetEur: "",
|
||||
startDate: today,
|
||||
endDate: today,
|
||||
status: "DRAFT",
|
||||
responsiblePerson: "",
|
||||
utilizationCategoryId: "",
|
||||
clientId: "",
|
||||
};
|
||||
}
|
||||
|
||||
function projectToForm(project: Project): FormState {
|
||||
return {
|
||||
shortCode: project.shortCode,
|
||||
name: project.name,
|
||||
orderType: project.orderType,
|
||||
allocationType: project.allocationType,
|
||||
winProbability: String(project.winProbability),
|
||||
budgetEur: String(Math.round(project.budgetCents) / 100),
|
||||
startDate: formatDateForInput(project.startDate),
|
||||
endDate: formatDateForInput(project.endDate),
|
||||
status: project.status,
|
||||
responsiblePerson: project.responsiblePerson ?? "",
|
||||
utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
|
||||
clientId: (project as unknown as { clientId?: string | null }).clientId ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
interface ProjectModalProps {
|
||||
project?: Project | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ProjectModal({ project, onClose }: ProjectModalProps) {
|
||||
const isEdit = !!project;
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [form, setForm] = useState<FormState>(() =>
|
||||
project ? projectToForm(project) : getDefaultForm(),
|
||||
);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const { data: utilizationCategories } = trpc.utilizationCategory.list.useQuery(undefined, { staleTime: 60_000 });
|
||||
const { data: clientList } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
|
||||
|
||||
// @ts-ignore TS2589: tRPC infers union type too deeply for CreateProjectSchema with .refine()
|
||||
const createMutation = trpc.project.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.project.list.invalidate();
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setServerError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = trpc.project.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.project.list.invalidate();
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setServerError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
setErrors((prev) => ({ ...prev, [key]: undefined }));
|
||||
setServerError(null);
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
const newErrors: Partial<Record<keyof FormState, string>> = {};
|
||||
|
||||
if (!isEdit && !form.shortCode.trim()) {
|
||||
newErrors.shortCode = "Short code is required.";
|
||||
} else if (!isEdit && !/^[A-Z0-9_-]+$/.test(form.shortCode.trim())) {
|
||||
newErrors.shortCode = "Must be uppercase alphanumeric (A-Z, 0-9, _, -).";
|
||||
}
|
||||
|
||||
if (!form.name.trim()) {
|
||||
newErrors.name = "Name is required.";
|
||||
}
|
||||
|
||||
const winProb = Number(form.winProbability);
|
||||
if (isNaN(winProb) || winProb < 0 || winProb > 100) {
|
||||
newErrors.winProbability = "Must be between 0 and 100.";
|
||||
}
|
||||
|
||||
const budget = parseFloat(form.budgetEur);
|
||||
if (isNaN(budget) || budget < 0) {
|
||||
newErrors.budgetEur = "Must be a positive number.";
|
||||
}
|
||||
|
||||
if (!form.startDate) {
|
||||
newErrors.startDate = "Start date is required.";
|
||||
}
|
||||
|
||||
if (!form.endDate) {
|
||||
newErrors.endDate = "End date is required.";
|
||||
} else if (form.startDate && form.endDate < form.startDate) {
|
||||
newErrors.endDate = "End date must be on or after start date.";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
const budgetCents = Math.round(parseFloat(form.budgetEur) * 100);
|
||||
const winProbability = Number(form.winProbability);
|
||||
|
||||
if (isEdit && project) {
|
||||
updateMutation.mutate({
|
||||
id: project.id,
|
||||
data: {
|
||||
name: form.name.trim(),
|
||||
orderType: form.orderType as unknown as OrderType,
|
||||
allocationType: form.allocationType as unknown as AllocationType,
|
||||
winProbability,
|
||||
budgetCents,
|
||||
startDate: new Date(form.startDate),
|
||||
endDate: new Date(form.endDate),
|
||||
status: form.status as unknown as ProjectStatus,
|
||||
responsiblePerson: form.responsiblePerson.trim() || undefined,
|
||||
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
||||
...(form.clientId ? { clientId: form.clientId } : {}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
createMutation.mutate({
|
||||
shortCode: form.shortCode.trim(),
|
||||
name: form.name.trim(),
|
||||
orderType: form.orderType as unknown as OrderType,
|
||||
allocationType: form.allocationType as unknown as AllocationType,
|
||||
winProbability,
|
||||
budgetCents,
|
||||
startDate: new Date(form.startDate),
|
||||
endDate: new Date(form.endDate),
|
||||
status: form.status as unknown as ProjectStatus,
|
||||
staffingReqs: [],
|
||||
dynamicFields: {},
|
||||
responsiblePerson: form.responsiblePerson.trim() || undefined,
|
||||
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
|
||||
...(form.clientId ? { clientId: form.clientId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
||||
const inputErrorClass =
|
||||
"w-full px-3 py-2 border border-red-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-400 text-sm dark:bg-gray-900 dark:text-gray-100";
|
||||
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-xl mx-4"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{isEdit ? "Edit Project" : "New Project"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<div className="px-6 py-5 space-y-6">
|
||||
{serverError && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||
{serverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 1: Identity */}
|
||||
<fieldset>
|
||||
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Identity
|
||||
</legend>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="shortCode">
|
||||
Chargecode <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="shortCode"
|
||||
type="text"
|
||||
value={form.shortCode}
|
||||
onChange={(e) => setField("shortCode", e.target.value.toUpperCase())}
|
||||
disabled={isEdit}
|
||||
placeholder="PRJ-001"
|
||||
className={
|
||||
isEdit
|
||||
? `${inputClass} bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed`
|
||||
: errors.shortCode
|
||||
? inputErrorClass
|
||||
: inputClass
|
||||
}
|
||||
/>
|
||||
{errors.shortCode && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.shortCode}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="name">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setField("name", e.target.value)}
|
||||
placeholder="Project name"
|
||||
className={errors.name ? inputErrorClass : inputClass}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Section 2: Classification */}
|
||||
<fieldset>
|
||||
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Classification
|
||||
</legend>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="orderType">
|
||||
Order Type
|
||||
</label>
|
||||
<select
|
||||
id="orderType"
|
||||
value={form.orderType}
|
||||
onChange={(e) => setField("orderType", e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{ORDER_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="allocationType">
|
||||
Allocation
|
||||
</label>
|
||||
<select
|
||||
id="allocationType"
|
||||
value={form.allocationType}
|
||||
onChange={(e) => setField("allocationType", e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{ALLOCATION_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="winProbability">
|
||||
Win Probability %
|
||||
</label>
|
||||
<input
|
||||
id="winProbability"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={form.winProbability}
|
||||
onChange={(e) => setField("winProbability", e.target.value)}
|
||||
className={errors.winProbability ? inputErrorClass : inputClass}
|
||||
/>
|
||||
{errors.winProbability && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.winProbability}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Section: Categorization */}
|
||||
<fieldset>
|
||||
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Categorization
|
||||
</legend>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="utilizationCategoryId">
|
||||
Utilization Category
|
||||
</label>
|
||||
<select
|
||||
id="utilizationCategoryId"
|
||||
value={form.utilizationCategoryId}
|
||||
onChange={(e) => setField("utilizationCategoryId", e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{(utilizationCategories ?? []).map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{(cat as unknown as { code: string }).code} — {(cat as unknown as { name: string }).name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="clientId">
|
||||
Client
|
||||
</label>
|
||||
<select
|
||||
id="clientId"
|
||||
value={form.clientId}
|
||||
onChange={(e) => setField("clientId", e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{(clientList ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{(c as unknown as { name: string }).name}
|
||||
{(c as unknown as { code: string | null }).code ? ` [${(c as unknown as { code: string }).code}]` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Section 3: Timeline & Budget */}
|
||||
<fieldset>
|
||||
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Timeline & Budget
|
||||
</legend>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="startDate">
|
||||
Start Date
|
||||
</label>
|
||||
<DateInput
|
||||
id="startDate"
|
||||
value={form.startDate}
|
||||
onChange={(v) => setField("startDate", v)}
|
||||
className={errors.startDate ? inputErrorClass : inputClass}
|
||||
/>
|
||||
{errors.startDate && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.startDate}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="endDate">
|
||||
End Date
|
||||
</label>
|
||||
<DateInput
|
||||
id="endDate"
|
||||
value={form.endDate}
|
||||
min={form.startDate}
|
||||
onChange={(v) => setField("endDate", v)}
|
||||
className={errors.endDate ? inputErrorClass : inputClass}
|
||||
/>
|
||||
{errors.endDate && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.endDate}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="budgetEur">
|
||||
Budget (€)
|
||||
</label>
|
||||
<input
|
||||
id="budgetEur"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={form.budgetEur}
|
||||
onChange={(e) => setField("budgetEur", e.target.value)}
|
||||
placeholder="0.00"
|
||||
className={errors.budgetEur ? inputErrorClass : inputClass}
|
||||
/>
|
||||
{errors.budgetEur && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.budgetEur}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Section 4: Status */}
|
||||
<fieldset>
|
||||
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Status
|
||||
</legend>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="status">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
value={form.status}
|
||||
onChange={(e) => setField("status", e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="responsiblePerson">
|
||||
Responsible Person
|
||||
</label>
|
||||
<input
|
||||
id="responsiblePerson"
|
||||
type="text"
|
||||
value={form.responsiblePerson}
|
||||
onChange={(e) => setField("responsiblePerson", e.target.value)}
|
||||
placeholder="Name or EID"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isLoading ? (isEdit ? "Saving…" : "Creating…") : isEdit ? "Save Changes" : "Create Project"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
// @react-pdf/renderer runs server-side only — no "use client" directive
|
||||
import { Document, Page, StyleSheet, Text, View } from "@react-pdf/renderer";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { padding: 30, fontFamily: "Helvetica", fontSize: 10 },
|
||||
title: { fontSize: 18, marginBottom: 4, fontFamily: "Helvetica-Bold" },
|
||||
subtitle: { fontSize: 11, color: "#6b7280", marginBottom: 20 },
|
||||
table: { marginTop: 10 },
|
||||
tableHeader: { flexDirection: "row", backgroundColor: "#f3f4f6", padding: "6 8", borderBottom: "1 solid #e5e7eb" },
|
||||
tableRow: { flexDirection: "row", padding: "5 8", borderBottom: "1 solid #f3f4f6" },
|
||||
col1: { width: "25%" },
|
||||
col2: { width: "20%" },
|
||||
col3: { width: "15%" },
|
||||
col4: { width: "15%" },
|
||||
col5: { width: "15%" },
|
||||
col6: { width: "10%" },
|
||||
headerText: { fontFamily: "Helvetica-Bold", color: "#374151", fontSize: 9 },
|
||||
cellText: { color: "#4b5563", fontSize: 9 },
|
||||
footer: { position: "absolute", bottom: 20, left: 30, right: 30, textAlign: "center", color: "#9ca3af", fontSize: 8 },
|
||||
});
|
||||
|
||||
interface AllocationRow {
|
||||
resourceName: string;
|
||||
projectName: string;
|
||||
role?: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
hoursPerDay: number;
|
||||
dailyCostCents: number;
|
||||
}
|
||||
|
||||
interface AllocationReportProps {
|
||||
title: string;
|
||||
generatedAt: string;
|
||||
rows: AllocationRow[];
|
||||
}
|
||||
|
||||
export function AllocationReport({ title, generatedAt, rows }: AllocationReportProps) {
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" orientation="landscape" style={styles.page}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.subtitle}>Generated: {generatedAt}</Text>
|
||||
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={[styles.col1, styles.headerText]}>Resource</Text>
|
||||
<Text style={[styles.col2, styles.headerText]}>Project</Text>
|
||||
<Text style={[styles.col3, styles.headerText]}>Role</Text>
|
||||
<Text style={[styles.col4, styles.headerText]}>Start</Text>
|
||||
<Text style={[styles.col5, styles.headerText]}>End</Text>
|
||||
<Text style={[styles.col6, styles.headerText]}>h/day</Text>
|
||||
</View>
|
||||
{rows.map((row, i) => (
|
||||
<View key={i} style={[styles.tableRow, i % 2 === 1 ? { backgroundColor: "#f9fafb" } : {}]}>
|
||||
<Text style={[styles.col1, styles.cellText]}>{row.resourceName}</Text>
|
||||
<Text style={[styles.col2, styles.cellText]}>{row.projectName}</Text>
|
||||
<Text style={[styles.col3, styles.cellText]}>{row.role ?? "—"}</Text>
|
||||
<Text style={[styles.col4, styles.cellText]}>{row.startDate}</Text>
|
||||
<Text style={[styles.col5, styles.cellText]}>{row.endDate}</Text>
|
||||
<Text style={[styles.col6, styles.cellText]}>{row.hoursPerDay}h</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={styles.footer}>Planarchy · Confidential · {rows.length} allocations</Text>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatMonth(key: string): string {
|
||||
const [y, m] = key.split("-");
|
||||
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
return `${months[Number(m) - 1]} ${y}`;
|
||||
}
|
||||
|
||||
function pct(ratio: number): string {
|
||||
return `${Math.round(ratio * 100)}%`;
|
||||
}
|
||||
|
||||
function chgColor(ratio: number, target: number): string {
|
||||
if (ratio >= target) return "text-green-700 dark:text-green-400";
|
||||
if (ratio >= target - 0.1) return "text-yellow-700 dark:text-yellow-400";
|
||||
return "text-red-700 dark:text-red-400";
|
||||
}
|
||||
|
||||
function barStyle(ratio: number, color: string): React.CSSProperties {
|
||||
return {
|
||||
background: `linear-gradient(to right, ${color} ${Math.min(ratio * 100, 100)}%, transparent ${Math.min(ratio * 100, 100)}%)`,
|
||||
};
|
||||
}
|
||||
|
||||
type GroupByField = "none" | "orgUnit" | "mgmtGroup" | "country";
|
||||
|
||||
interface ResourceRow {
|
||||
id: string;
|
||||
eid: string;
|
||||
displayName: string;
|
||||
fte: number;
|
||||
country: string | null;
|
||||
city: string | null;
|
||||
orgUnit: string | null;
|
||||
mgmtGroup: string | null;
|
||||
mgmtLevel: string | null;
|
||||
targetPct: number;
|
||||
months: MonthData[];
|
||||
}
|
||||
|
||||
interface MonthData {
|
||||
monthKey: string;
|
||||
sah: number;
|
||||
chg: number;
|
||||
bd: number;
|
||||
mdi: number;
|
||||
mo: number;
|
||||
pdr: number;
|
||||
absence: number;
|
||||
unassigned: number;
|
||||
}
|
||||
|
||||
interface GroupSummary {
|
||||
label: string;
|
||||
resources: ResourceRow[];
|
||||
monthTotals: { monthKey: string; chg: number; target: number; gap: number; totalFte: number }[];
|
||||
}
|
||||
|
||||
function computeGroupMonthTotals(
|
||||
resources: ResourceRow[],
|
||||
monthKeys: string[],
|
||||
): GroupSummary["monthTotals"] {
|
||||
const totalFte = resources.reduce((sum, r) => sum + r.fte, 0);
|
||||
return monthKeys.map((key, idx) => {
|
||||
if (totalFte === 0) return { monthKey: key, chg: 0, target: 0, gap: 0, totalFte: 0 };
|
||||
const chg = resources.reduce((sum, r) => sum + r.fte * r.months[idx]!.chg, 0) / totalFte;
|
||||
const target = resources.reduce((sum, r) => sum + r.fte * r.targetPct, 0) / totalFte;
|
||||
return { monthKey: key, chg, target, gap: chg - target, totalFte };
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Export ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function exportToExcel(
|
||||
resources: ResourceRow[],
|
||||
monthKeys: string[],
|
||||
groupTotals: { monthKey: string; chg: number; target: number; gap: number; totalFte: number }[],
|
||||
groups: GroupSummary[],
|
||||
groupBy: GroupByField,
|
||||
) {
|
||||
const XLSX = await import("xlsx");
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
// Build main data rows
|
||||
const headers = ["Name", "EID", "FTE", "Target", "Country", "City", "Org Unit", "Mgmt Group", "Mgmt Level"];
|
||||
for (const key of monthKeys) {
|
||||
headers.push(`Chg ${formatMonth(key)}`);
|
||||
headers.push(`BD ${formatMonth(key)}`);
|
||||
headers.push(`MD&I ${formatMonth(key)}`);
|
||||
headers.push(`M&O ${formatMonth(key)}`);
|
||||
headers.push(`PD&R ${formatMonth(key)}`);
|
||||
headers.push(`Abs ${formatMonth(key)}`);
|
||||
headers.push(`Free ${formatMonth(key)}`);
|
||||
headers.push(`SAH ${formatMonth(key)}`);
|
||||
}
|
||||
|
||||
const rows: (string | number)[][] = [headers];
|
||||
|
||||
// Group total row
|
||||
const totalRow: (string | number)[] = [
|
||||
"GROUP TOTAL", "", groupTotals[0]?.totalFte ?? 0, groupTotals[0]?.target ?? 0,
|
||||
"", "", "", "", "",
|
||||
];
|
||||
for (const gt of groupTotals) {
|
||||
totalRow.push(Math.round(gt.chg * 100) / 100, 0, 0, 0, 0, 0, 0, 0);
|
||||
}
|
||||
rows.push(totalRow);
|
||||
|
||||
// Sub-group totals if grouping
|
||||
if (groupBy !== "none") {
|
||||
for (const g of groups) {
|
||||
const subRow: (string | number)[] = [
|
||||
` ${g.label} (${g.resources.length})`, "", g.monthTotals[0]?.totalFte ?? 0,
|
||||
g.monthTotals[0]?.target ?? 0, "", "", "", "", "",
|
||||
];
|
||||
for (const mt of g.monthTotals) {
|
||||
subRow.push(Math.round(mt.chg * 100) / 100, 0, 0, 0, 0, 0, 0, 0);
|
||||
}
|
||||
rows.push(subRow);
|
||||
}
|
||||
rows.push([]); // blank separator
|
||||
}
|
||||
|
||||
// Resource rows
|
||||
for (const r of resources) {
|
||||
const row: (string | number)[] = [
|
||||
r.displayName, r.eid, r.fte, Math.round(r.targetPct * 100) / 100,
|
||||
r.country ?? "", r.city ?? "", r.orgUnit ?? "", r.mgmtGroup ?? "", r.mgmtLevel ?? "",
|
||||
];
|
||||
for (const m of r.months) {
|
||||
row.push(
|
||||
Math.round(m.chg * 1000) / 1000,
|
||||
Math.round(m.bd * 1000) / 1000,
|
||||
Math.round(m.mdi * 1000) / 1000,
|
||||
Math.round(m.mo * 1000) / 1000,
|
||||
Math.round(m.pdr * 1000) / 1000,
|
||||
Math.round(m.absence * 1000) / 1000,
|
||||
Math.round(m.unassigned * 1000) / 1000,
|
||||
Math.round(m.sah * 100) / 100,
|
||||
);
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Chargeability");
|
||||
XLSX.writeFile(wb, `chargeability-report.xlsx`);
|
||||
}
|
||||
|
||||
function exportToCsv(
|
||||
resources: ResourceRow[],
|
||||
monthKeys: string[],
|
||||
) {
|
||||
const headers = ["Name", "EID", "FTE", "Target", "Country", "City", "Org Unit", "Mgmt Group", "Mgmt Level"];
|
||||
for (const key of monthKeys) {
|
||||
headers.push(`Chg_${key}`, `BD_${key}`, `MDI_${key}`, `MO_${key}`, `PDR_${key}`, `Abs_${key}`, `Free_${key}`, `SAH_${key}`);
|
||||
}
|
||||
|
||||
const lines = [headers.join(",")];
|
||||
for (const r of resources) {
|
||||
const vals = [
|
||||
`"${r.displayName}"`, r.eid, r.fte, r.targetPct,
|
||||
r.country ?? "", r.city ?? "", r.orgUnit ?? "", r.mgmtGroup ?? "", r.mgmtLevel ?? "",
|
||||
];
|
||||
for (const m of r.months) {
|
||||
vals.push(
|
||||
m.chg as unknown as string, m.bd as unknown as string, m.mdi as unknown as string,
|
||||
m.mo as unknown as string, m.pdr as unknown as string, m.absence as unknown as string,
|
||||
m.unassigned as unknown as string, m.sah as unknown as string,
|
||||
);
|
||||
}
|
||||
lines.push(vals.join(","));
|
||||
}
|
||||
|
||||
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "chargeability-report.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
const NOW = new Date();
|
||||
const DEFAULT_START = `${NOW.getFullYear()}-${String(NOW.getMonth() + 1).padStart(2, "0")}`;
|
||||
const DEFAULT_END_DATE = new Date(NOW.getFullYear(), NOW.getMonth() + 5, 1);
|
||||
const DEFAULT_END = `${DEFAULT_END_DATE.getFullYear()}-${String(DEFAULT_END_DATE.getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
export function ChargeabilityReportClient() {
|
||||
const [startMonth, setStartMonth] = useState(DEFAULT_START);
|
||||
const [endMonth, setEndMonth] = useState(DEFAULT_END);
|
||||
const [orgUnitId, setOrgUnitId] = useState<string>("");
|
||||
const [mgmtGroupId, setMgmtGroupId] = useState<string>("");
|
||||
const [countryId, setCountryId] = useState<string>("");
|
||||
const [groupBy, setGroupBy] = useState<GroupByField>("none");
|
||||
const [expandedResource, setExpandedResource] = useState<string | null>(null);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// Filter dropdowns data
|
||||
const orgUnitsQuery = trpc.orgUnit.list.useQuery();
|
||||
const mgmtGroupsQuery = trpc.managementLevel.listGroups.useQuery();
|
||||
const countriesQuery = trpc.country.list.useQuery();
|
||||
|
||||
const reportQuery = trpc.chargeabilityReport.getReport.useQuery(
|
||||
{
|
||||
startMonth,
|
||||
endMonth,
|
||||
...(orgUnitId ? { orgUnitId } : {}),
|
||||
...(mgmtGroupId ? { managementLevelGroupId: mgmtGroupId } : {}),
|
||||
...(countryId ? { countryId } : {}),
|
||||
},
|
||||
{ placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
const data = reportQuery.data;
|
||||
|
||||
const orgUnits = useMemo(() => {
|
||||
const items = orgUnitsQuery.data ?? [];
|
||||
return items.filter((u: { level: number }) => u.level === 7);
|
||||
}, [orgUnitsQuery.data]);
|
||||
|
||||
// Group resources by selected dimension
|
||||
const groups = useMemo((): GroupSummary[] => {
|
||||
if (!data || groupBy === "none") return [];
|
||||
|
||||
const buckets = new Map<string, ResourceRow[]>();
|
||||
for (const r of data.resources) {
|
||||
let key: string;
|
||||
switch (groupBy) {
|
||||
case "orgUnit": key = r.orgUnit ?? "(No Org Unit)"; break;
|
||||
case "mgmtGroup": key = r.mgmtGroup ?? "(No Mgmt Group)"; break;
|
||||
case "country": key = r.country ?? "(No Country)"; break;
|
||||
}
|
||||
const list = buckets.get(key) ?? [];
|
||||
list.push(r);
|
||||
buckets.set(key, list);
|
||||
}
|
||||
|
||||
return Array.from(buckets.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([label, resources]) => ({
|
||||
label,
|
||||
resources,
|
||||
monthTotals: computeGroupMonthTotals(resources, data.monthKeys),
|
||||
}));
|
||||
}, [data, groupBy]);
|
||||
|
||||
const toggleGroup = useCallback((label: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) next.delete(label);
|
||||
else next.add(label);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleExportExcel = useCallback(() => {
|
||||
if (!data) return;
|
||||
exportToExcel(data.resources, data.monthKeys, data.groupTotals, groups, groupBy);
|
||||
}, [data, groups, groupBy]);
|
||||
|
||||
const handleExportCsv = useCallback(() => {
|
||||
if (!data) return;
|
||||
exportToCsv(data.resources, data.monthKeys);
|
||||
}, [data]);
|
||||
|
||||
// ─── Render helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function renderResourceRow(r: ResourceRow) {
|
||||
return (
|
||||
<tr
|
||||
key={r.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
|
||||
onClick={() => setExpandedResource(expandedResource === r.id ? null : r.id)}
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-white dark:bg-gray-900 px-3 py-2">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">{r.displayName}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{[r.eid, r.country, r.city, r.orgUnit].filter(Boolean).join(" | ")}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center text-gray-600 dark:text-gray-400">{r.fte.toFixed(2)}</td>
|
||||
<td className="px-2 py-2 text-center text-gray-600 dark:text-gray-400">{pct(r.targetPct)}</td>
|
||||
{r.months.map((m) => (
|
||||
<td key={m.monthKey} className="px-2 py-2 text-center">
|
||||
<div
|
||||
className={`rounded px-1 ${chgColor(m.chg, r.targetPct)}`}
|
||||
style={barStyle(m.chg, m.chg >= r.targetPct ? "rgba(34,197,94,0.15)" : "rgba(239,68,68,0.1)")}
|
||||
>
|
||||
{pct(m.chg)}
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function renderExpandedRow(r: ResourceRow) {
|
||||
if (expandedResource !== r.id) return null;
|
||||
return (
|
||||
<tr key={`${r.id}-detail`} className="bg-gray-50 dark:bg-gray-800/30">
|
||||
<td className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-800/30 px-3 py-2" colSpan={3}>
|
||||
<div className="text-xs space-y-0.5 text-gray-500 dark:text-gray-400">
|
||||
<div>Mgmt: {r.mgmtGroup ?? "—"} / {r.mgmtLevel ?? "—"}</div>
|
||||
<div className="mt-1 grid grid-cols-7 gap-1 text-[10px]">
|
||||
<span className="font-medium">Chg</span>
|
||||
<span className="font-medium">BD</span>
|
||||
<span className="font-medium">MD&I</span>
|
||||
<span className="font-medium">M&O</span>
|
||||
<span className="font-medium">PD&R</span>
|
||||
<span className="font-medium">Abs</span>
|
||||
<span className="font-medium">Free</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{r.months.map((m) => (
|
||||
<td key={m.monthKey} className="px-2 py-2 text-center">
|
||||
<div className="grid grid-cols-1 gap-0.5 text-[10px] text-gray-500 dark:text-gray-400">
|
||||
<span className="text-green-600">{pct(m.chg)}</span>
|
||||
<span>{pct(m.bd)}</span>
|
||||
<span>{pct(m.mdi)}</span>
|
||||
<span>{pct(m.mo)}</span>
|
||||
<span>{pct(m.pdr)}</span>
|
||||
<span className="text-orange-500">{pct(m.absence)}</span>
|
||||
<span className="text-gray-400">{pct(m.unassigned)}</span>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function renderGroupTotalsRow(
|
||||
label: string,
|
||||
monthTotals: GroupSummary["monthTotals"],
|
||||
count: number,
|
||||
isOverall: boolean,
|
||||
onClick?: () => void,
|
||||
) {
|
||||
const bg = isOverall
|
||||
? "bg-brand-50 dark:bg-brand-900/20"
|
||||
: "bg-indigo-50 dark:bg-indigo-900/20";
|
||||
return (
|
||||
<tr
|
||||
className={`${bg} font-semibold ${onClick ? "cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<td className={`sticky left-0 z-10 ${bg} px-3 py-2 text-gray-900 dark:text-gray-100`}>
|
||||
{onClick && <span className="mr-1 text-xs">{expandedGroups.has(label) ? "▾" : "▸"}</span>}
|
||||
{label} ({count} resources)
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">
|
||||
{monthTotals[0]?.totalFte.toFixed(1)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">
|
||||
{monthTotals[0] ? pct(monthTotals[0].target) : "—"}
|
||||
</td>
|
||||
{monthTotals.map((mt) => (
|
||||
<td key={mt.monthKey} className="px-2 py-2 text-center">
|
||||
<div className={chgColor(mt.chg, mt.target)}>{pct(mt.chg)}</div>
|
||||
{mt.gap !== 0 && (
|
||||
<div className="text-xs text-gray-400">
|
||||
{mt.gap > 0 ? "+" : ""}{pct(mt.gap)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main render ─────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">
|
||||
Chargeability Forecast
|
||||
</h1>
|
||||
{/* Export buttons */}
|
||||
{data && data.resources.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleExportExcel}
|
||||
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Export Excel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportCsv}
|
||||
className="px-3 py-1.5 text-sm bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 items-end bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">From</label>
|
||||
<input
|
||||
type="month"
|
||||
value={startMonth}
|
||||
onChange={(e) => setStartMonth(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">To</label>
|
||||
<input
|
||||
type="month"
|
||||
value={endMonth}
|
||||
onChange={(e) => setEndMonth(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Country</label>
|
||||
<select
|
||||
value={countryId}
|
||||
onChange={(e) => setCountryId(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{(countriesQuery.data ?? []).map((c: { id: string; code: string; name: string }) => (
|
||||
<option key={c.id} value={c.id}>{c.code} — {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Org Unit</label>
|
||||
<select
|
||||
value={orgUnitId}
|
||||
onChange={(e) => setOrgUnitId(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{orgUnits.map((u: { id: string; name: string }) => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Mgmt Level Group</label>
|
||||
<select
|
||||
value={mgmtGroupId}
|
||||
onChange={(e) => setMgmtGroupId(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{(mgmtGroupsQuery.data ?? []).map((g: { id: string; name: string }) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Group By</label>
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => { setGroupBy(e.target.value as GroupByField); setExpandedGroups(new Set()); }}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="none">No Grouping</option>
|
||||
<option value="orgUnit">Org Unit</option>
|
||||
<option value="mgmtGroup">Mgmt Level Group</option>
|
||||
<option value="country">Country</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report table */}
|
||||
{reportQuery.isLoading && !data ? (
|
||||
<div className="text-center py-12 text-gray-500">Loading report...</div>
|
||||
) : reportQuery.error ? (
|
||||
<div className="text-center py-12 text-red-600">
|
||||
Error: {reportQuery.error.message}
|
||||
</div>
|
||||
) : data && data.resources.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No resources match the current filters.
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-800">
|
||||
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400 min-w-[200px]">
|
||||
Resource
|
||||
</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 w-16">FTE</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 w-16">Target</th>
|
||||
{data.monthKeys.map((key) => (
|
||||
<th key={key} className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 min-w-[80px]">
|
||||
{formatMonth(key)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{/* Overall group total */}
|
||||
{renderGroupTotalsRow("Group Total", data.groupTotals, data.resources.length, true)}
|
||||
|
||||
{/* Grouped view */}
|
||||
{groupBy !== "none" ? (
|
||||
groups.map((g) => (
|
||||
<React.Fragment key={g.label}>
|
||||
{renderGroupTotalsRow(g.label, g.monthTotals, g.resources.length, false, () => toggleGroup(g.label))}
|
||||
{expandedGroups.has(g.label) && g.resources.map((r) => (
|
||||
<React.Fragment key={r.id}>
|
||||
{renderResourceRow(r)}
|
||||
{renderExpandedRow(r)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
data.resources.map((r) => (
|
||||
<React.Fragment key={r.id}>
|
||||
{renderResourceRow(r)}
|
||||
{renderExpandedRow(r)}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { formatDate } from "~/lib/format.js";
|
||||
|
||||
interface Props {
|
||||
resourceId: string;
|
||||
aiSummary: string | null;
|
||||
aiSummaryUpdatedAt: Date | string | null;
|
||||
onGenerated: () => void;
|
||||
}
|
||||
|
||||
export function AiSummaryCard({ resourceId, aiSummary, aiSummaryUpdatedAt, onGenerated }: Props) {
|
||||
const [localSummary, setLocalSummary] = useState<string | null>(aiSummary);
|
||||
const [localUpdatedAt, setLocalUpdatedAt] = useState<Date | string | null>(aiSummaryUpdatedAt);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { canEdit } = usePermissions();
|
||||
|
||||
// Keep local state in sync if the parent refreshes with newer data
|
||||
if (aiSummary && aiSummary !== localSummary) {
|
||||
setLocalSummary(aiSummary);
|
||||
setLocalUpdatedAt(aiSummaryUpdatedAt);
|
||||
}
|
||||
|
||||
const generateMutation = trpc.resource.generateAiSummary.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setError(null);
|
||||
setLocalSummary(data.summary);
|
||||
setLocalUpdatedAt(new Date());
|
||||
onGenerated();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message ?? "Failed to generate summary");
|
||||
},
|
||||
});
|
||||
|
||||
const { data: aiConfigured } = trpc.settings.getAiConfigured.useQuery(undefined, {
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-gray-800">AI Profile Summary</h2>
|
||||
{canEdit && aiConfigured?.configured && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => generateMutation.mutate({ resourceId })}
|
||||
disabled={generateMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{generateMutation.isPending ? (
|
||||
<>
|
||||
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Generating…
|
||||
</>
|
||||
) : localSummary ? (
|
||||
"Regenerate"
|
||||
) : (
|
||||
"Generate"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-xs text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localSummary ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{localSummary}</p>
|
||||
{localUpdatedAt && (
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Generated {formatDate(localUpdatedAt)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 italic">
|
||||
{!aiConfigured?.configured
|
||||
? "AI not configured. Set up your API key in Admin → Settings."
|
||||
: canEdit
|
||||
? "No summary yet. Click Generate to create one."
|
||||
: "No summary generated yet."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FieldType } from "@planarchy/shared";
|
||||
import type { BlueprintFieldDefinition } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
const INPUT_CLS =
|
||||
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
|
||||
|
||||
interface Props {
|
||||
selectedIds: string[];
|
||||
fieldDefs: BlueprintFieldDefinition[];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function BulkEditModal({ selectedIds, fieldDefs, onClose, onSuccess }: Props) {
|
||||
// Track which fields are included in the bulk update + their new values
|
||||
const [included, setIncluded] = useState<Set<string>>(new Set());
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.resource.batchUpdateCustomFields.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.resource.list.invalidate();
|
||||
onSuccess();
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
});
|
||||
|
||||
function toggleInclude(key: string) {
|
||||
setIncluded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) { next.delete(key); } else { next.add(key); }
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function setValue(key: string, value: unknown) {
|
||||
setValues((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setError(null);
|
||||
const fields: Record<string, unknown> = {};
|
||||
for (const key of included) {
|
||||
fields[key] = values[key] ?? "";
|
||||
}
|
||||
if (Object.keys(fields).length === 0) {
|
||||
setError("Select at least one field to update.");
|
||||
return;
|
||||
}
|
||||
mutation.mutate({ ids: selectedIds, fields });
|
||||
}
|
||||
|
||||
function handleBackdrop(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
|
||||
const editableFields = fieldDefs.filter((f) => !f.required || included.has(f.key));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={handleBackdrop}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Bulk Edit Custom Fields</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Updating {selectedIds.length} resource{selectedIds.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
{fieldDefs.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-6">No custom fields defined. Configure them in Admin → Blueprints.</p>
|
||||
)}
|
||||
{fieldDefs.map((field) => (
|
||||
<div key={field.key} className={`border rounded-lg p-3 transition-colors ${included.has(field.key) ? "border-brand-300 bg-brand-50" : "border-gray-200"}`}>
|
||||
<label className="flex items-center gap-2 mb-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={included.has(field.key)}
|
||||
onChange={() => toggleInclude(field.key)}
|
||||
className="rounded border-gray-300 text-brand-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">{field.label}</span>
|
||||
{field.required && <span className="text-xs text-red-500">required</span>}
|
||||
</label>
|
||||
|
||||
{included.has(field.key) && (
|
||||
<FieldInput
|
||||
field={field}
|
||||
value={values[field.key]}
|
||||
onChange={(v) => setValue(field.key, v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-6 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
|
||||
<p className="text-xs text-gray-400">{included.size} field{included.size !== 1 ? "s" : ""} selected</p>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={mutation.isPending || included.size === 0}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Saving…" : `Apply to ${selectedIds.length} resource${selectedIds.length !== 1 ? "s" : ""}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldInput({ field, value, onChange }: { field: BlueprintFieldDefinition; value: unknown; onChange: (v: unknown) => void }) {
|
||||
const str = value !== undefined && value !== null ? String(value) : "";
|
||||
|
||||
if (field.type === FieldType.BOOLEAN) {
|
||||
return (
|
||||
<select value={str} onChange={(e) => onChange(e.target.value === "true")} className={INPUT_CLS}>
|
||||
<option value="">— select —</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === FieldType.SELECT && field.options) {
|
||||
return (
|
||||
<select value={str} onChange={(e) => onChange(e.target.value)} className={INPUT_CLS}>
|
||||
<option value="">— select —</option>
|
||||
{field.options.map((o) => <option key={o.value} value={o.value}>{o.label || o.value}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === FieldType.NUMBER) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={str}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : "")}
|
||||
placeholder={field.placeholder}
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DATE) {
|
||||
return <input type="date" value={str} onChange={(e) => onChange(e.target.value)} className={INPUT_CLS} />;
|
||||
}
|
||||
|
||||
if (field.type === FieldType.TEXTAREA) {
|
||||
return (
|
||||
<textarea
|
||||
value={str}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className={`${INPUT_CLS} w-full resize-none`}
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type={field.type === FieldType.EMAIL ? "email" : field.type === FieldType.URL ? "url" : "text"}
|
||||
value={str}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className={`${INPUT_CLS} w-full`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export type { Props as BulkEditModalProps };
|
||||
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { parseSpreadsheet, isSpreadsheetFile } from "~/lib/excel.js";
|
||||
|
||||
type ImportStage = "idle" | "preview" | "importing" | "done";
|
||||
|
||||
interface ImportResult {
|
||||
total: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
errors: { row: number; message: string }[];
|
||||
dryRun: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImportModal({ onClose }: Props) {
|
||||
const [stage, setStage] = useState<ImportStage>("idle");
|
||||
const [rows, setRows] = useState<Record<string, string>[]>([]);
|
||||
const [fileName, setFileName] = useState<string>("");
|
||||
const [fileError, setFileError] = useState<string>("");
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const [dryRun, setDryRun] = useState(true);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const importMutation = trpc.importExport.importCSV.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setResult(data);
|
||||
setStage("done");
|
||||
},
|
||||
onError: (err) => {
|
||||
setFileError(err.message);
|
||||
setStage("preview");
|
||||
},
|
||||
});
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setFileError("");
|
||||
setRows([]);
|
||||
setResult(null);
|
||||
|
||||
if (!isSpreadsheetFile(file)) {
|
||||
setFileError("Unsupported file type. Please upload an Excel (.xlsx, .xls) or CSV file.");
|
||||
return;
|
||||
}
|
||||
|
||||
setFileName(file.name);
|
||||
|
||||
try {
|
||||
const parsed = await parseSpreadsheet(file);
|
||||
setRows(parsed);
|
||||
setStage("preview");
|
||||
} catch (err) {
|
||||
setFileError(err instanceof Error ? err.message : "Failed to parse file.");
|
||||
}
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
if (rows.length === 0) return;
|
||||
setStage("importing");
|
||||
importMutation.mutate({
|
||||
entityType: "resources",
|
||||
rows,
|
||||
dryRun,
|
||||
});
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setStage("idle");
|
||||
setRows([]);
|
||||
setFileName("");
|
||||
setFileError("");
|
||||
setResult(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const previewHeaders = rows.length > 0 ? Object.keys(rows[0]!) : [];
|
||||
const previewRows = rows.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Import Resources</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* File picker */}
|
||||
{stage === "idle" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Upload an Excel or CSV file to import resources. The first row must contain column headers
|
||||
matching the resource fields (e.g.{" "}
|
||||
<code className="px-1 py-0.5 bg-gray-100 rounded text-xs font-mono">
|
||||
eid, displayName, email, chapter, lcrCents
|
||||
</code>
|
||||
).
|
||||
</p>
|
||||
<label className="block">
|
||||
<span className="sr-only">Choose spreadsheet file</span>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-gray-300 rounded-xl cursor-pointer hover:border-brand-400 hover:bg-brand-50 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<svg className="w-10 h-10 text-gray-400 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="text-sm text-gray-500">Click to select Excel or CSV</p>
|
||||
<p className="text-xs text-gray-400 mt-1">.xlsx, .xls, .csv supported</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
{fileError && (
|
||||
<p className="text-sm text-red-600">{fileError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{(stage === "preview" || stage === "importing") && rows.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{fileName}</p>
|
||||
<p className="text-xs text-gray-500">{rows.length} row{rows.length !== 1 ? "s" : ""} parsed</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
||||
>
|
||||
Choose different file
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{previewHeaders.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
Preview (first {previewRows.length} of {rows.length} rows)
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table className="text-xs w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{previewHeaders.map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-3 py-2 text-left font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{previewRows.map((row, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50">
|
||||
{previewHeaders.map((h) => (
|
||||
<td key={h} className="px-3 py-2 text-gray-700 whitespace-nowrap max-w-[200px] truncate">
|
||||
{row[h] ?? ""}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{rows.length > 5 && (
|
||||
<p className="text-xs text-gray-400 mt-1">…and {rows.length - 5} more rows</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileError && (
|
||||
<p className="text-sm text-red-600">{fileError}</p>
|
||||
)}
|
||||
|
||||
{/* Import options */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dryRun"
|
||||
checked={dryRun}
|
||||
onChange={(e) => setDryRun(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="dryRun" className="text-sm text-gray-700">
|
||||
Dry run (validate only, do not write to database)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Done */}
|
||||
{stage === "done" && result && (
|
||||
<div className="space-y-4">
|
||||
<div className={`rounded-lg p-4 ${result.errors.length > 0 ? "bg-yellow-50 border border-yellow-200" : "bg-green-50 border border-green-200"}`}>
|
||||
<p className="text-sm font-medium text-gray-900 mb-1">
|
||||
{result.dryRun ? "Dry run complete" : "Import complete"}
|
||||
</p>
|
||||
<ul className="text-sm text-gray-700 space-y-0.5">
|
||||
<li>Total rows: <strong>{result.total}</strong></li>
|
||||
{!result.dryRun && (
|
||||
<>
|
||||
<li>Created: <strong>{result.created}</strong></li>
|
||||
<li>Updated: <strong>{result.updated}</strong></li>
|
||||
</>
|
||||
)}
|
||||
{result.message && <li>{result.message}</li>}
|
||||
{result.errors.length > 0 && (
|
||||
<li className="text-red-600">Errors: <strong>{result.errors.length}</strong></li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Errors</p>
|
||||
<ul className="text-xs text-red-600 space-y-0.5 max-h-32 overflow-y-auto">
|
||||
{result.errors.map((e, i) => (
|
||||
<li key={i}>Row {e.row}: {e.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.dryRun && result.errors.length === 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dryRunConfirm"
|
||||
checked={false}
|
||||
onChange={() => {
|
||||
setDryRun(false);
|
||||
setStage("preview");
|
||||
setResult(null);
|
||||
}}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="dryRunConfirm" className="text-sm text-gray-700 cursor-pointer">
|
||||
Validation passed — click here to run the actual import
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{(stage === "preview") && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
disabled={rows.length === 0 || importMutation.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{dryRun ? "Validate" : "Import"} {rows.length} row{rows.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
)}
|
||||
{stage === "importing" && (
|
||||
<span className="px-4 py-2 text-sm text-gray-500 animate-pulse">Importing…</span>
|
||||
)}
|
||||
{stage === "done" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Import another file
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { formatDate } from "~/lib/format.js";
|
||||
import { ResourceModal } from "./ResourceModal.js";
|
||||
import { SkillRadarChart } from "./SkillRadarChart.js";
|
||||
import { AiSummaryCard } from "./AiSummaryCard.js";
|
||||
import { SkillMatrixUpload } from "./SkillMatrixUpload.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
|
||||
interface ResourceDetailProps {
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
const proficiencyLabel: Record<number, string> = {
|
||||
1: "Beginner",
|
||||
2: "Elementary",
|
||||
3: "Intermediate",
|
||||
4: "Advanced",
|
||||
5: "Expert",
|
||||
};
|
||||
|
||||
const proficiencyColor: Record<number, string> = {
|
||||
1: "bg-gray-100 text-gray-600",
|
||||
2: "bg-blue-50 text-blue-600",
|
||||
3: "bg-brand-50 text-brand-700",
|
||||
4: "bg-amber-50 text-amber-700",
|
||||
5: "bg-green-50 text-green-700",
|
||||
};
|
||||
|
||||
const vacationStatusColor: Record<string, string> = {
|
||||
PENDING: "bg-yellow-100 text-yellow-700",
|
||||
APPROVED: "bg-green-100 text-green-700",
|
||||
REJECTED: "bg-red-100 text-red-700",
|
||||
CANCELLED: "bg-gray-100 text-gray-500",
|
||||
};
|
||||
|
||||
const allocationStatusColor: Record<string, string> = {
|
||||
PROPOSED: "bg-gray-100 text-gray-600",
|
||||
CONFIRMED: "bg-blue-100 text-blue-700",
|
||||
ACTIVE: "bg-green-100 text-green-700",
|
||||
COMPLETED: "bg-purple-100 text-purple-700",
|
||||
CANCELLED: "bg-red-100 text-red-500",
|
||||
};
|
||||
|
||||
function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500 mb-1">{label}</div>
|
||||
<div className="text-xl font-bold text-gray-900">{value}</div>
|
||||
{sub && <div className="text-xs text-gray-400 mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
const { canViewCosts, canEdit, canViewScores } = usePermissions();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const _resourceQuery = trpc.resource.getById.useQuery({ id: resourceId });
|
||||
const resource = _resourceQuery.data as unknown as Resource | undefined;
|
||||
const loadingResource = _resourceQuery.isLoading;
|
||||
const error = _resourceQuery.error;
|
||||
|
||||
// Fetch allocations for this resource (all non-cancelled)
|
||||
const now = new Date();
|
||||
const windowEnd = new Date(now);
|
||||
windowEnd.setDate(windowEnd.getDate() + 90);
|
||||
|
||||
const _allocQuery = trpc.allocation.listView.useQuery(
|
||||
{ resourceId },
|
||||
{ enabled: !!resourceId },
|
||||
) as { data: AllocationReadModel<AllocationLike> | undefined; isLoading: boolean };
|
||||
const allocations = (_allocQuery.data?.assignments ?? []) as unknown as Array<Pick<
|
||||
AllocationWithDetails,
|
||||
"id" | "startDate" | "endDate" | "hoursPerDay" | "dailyCostCents" | "status" | "role" | "roleEntity" | "project"
|
||||
>>;
|
||||
const loadingAllocations = _allocQuery.isLoading;
|
||||
|
||||
// Fetch upcoming/recent vacations
|
||||
const vacationStart = new Date(now);
|
||||
vacationStart.setMonth(vacationStart.getMonth() - 1);
|
||||
|
||||
const { data: vacations, isLoading: loadingVacations } = trpc.vacation.list.useQuery(
|
||||
{
|
||||
resourceId,
|
||||
startDate: vacationStart,
|
||||
limit: 20,
|
||||
},
|
||||
{ enabled: !!resourceId },
|
||||
);
|
||||
|
||||
const chargeabilityStatsResult = trpc.resource.getChargeabilityStats.useQuery(
|
||||
{ resourceId },
|
||||
{ enabled: canViewCosts, staleTime: 60_000 },
|
||||
);
|
||||
const chargeStats = (chargeabilityStatsResult.data as unknown as Array<{
|
||||
actualChargeability: number;
|
||||
expectedChargeability: number;
|
||||
}> | undefined)?.[0];
|
||||
|
||||
if (loadingResource) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-64" />
|
||||
<div className="h-4 bg-gray-100 rounded w-48" />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[0, 1, 2, 3].map((i) => <div key={i} className="h-20 bg-gray-100 rounded-xl" />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !resource) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-red-700 text-sm">
|
||||
Resource not found.{" "}
|
||||
<Link href="/resources" className="underline">Back to resources</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const skills = resource.skills as unknown as SkillEntry[];
|
||||
const resourceRoles = (resource as unknown as {
|
||||
resourceRoles?: { isPrimary: boolean; role: { id: string; name: string; color: string | null } }[];
|
||||
}).resourceRoles ?? [];
|
||||
const mainSkills = skills.filter((s) => s.isMainSkill);
|
||||
|
||||
// Determine if current user owns this resource (self-service)
|
||||
const resourceWithMeta = resource as unknown as {
|
||||
userId?: string | null;
|
||||
portfolioUrl?: string | null;
|
||||
roleId?: string | null;
|
||||
aiSummary?: string | null;
|
||||
aiSummaryUpdatedAt?: Date | null;
|
||||
skillMatrixUpdatedAt?: Date | null;
|
||||
areaRole?: { name: string } | null;
|
||||
valueScore?: number | null;
|
||||
valueScoreBreakdown?: {
|
||||
skillDepth: number;
|
||||
skillBreadth: number;
|
||||
costEfficiency: number;
|
||||
chargeability: number;
|
||||
experience: number;
|
||||
total: number;
|
||||
} | null;
|
||||
valueScoreUpdatedAt?: Date | null;
|
||||
};
|
||||
const currentUserEmail = session?.user?.email;
|
||||
const isOwner = !!(resourceWithMeta.userId && currentUserEmail &&
|
||||
(resource as unknown as { user?: { email?: string } }).user?.email === currentUserEmail);
|
||||
const canUpload = isOwner || canEdit;
|
||||
|
||||
// Compute stats
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
const thisMonthAllocs = (allocations ?? []).filter((a) => {
|
||||
const start = new Date(a.startDate);
|
||||
const end = new Date(a.endDate);
|
||||
return start <= monthEnd && end >= monthStart;
|
||||
});
|
||||
|
||||
let totalHoursThisMonth = 0;
|
||||
let totalCostCentsThisMonth = 0;
|
||||
const activeProjectIds = new Set<string>();
|
||||
|
||||
for (const a of thisMonthAllocs) {
|
||||
const start = new Date(Math.max(new Date(a.startDate).getTime(), monthStart.getTime()));
|
||||
const end = new Date(Math.min(new Date(a.endDate).getTime(), monthEnd.getTime()));
|
||||
const days = Math.max(0, (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + 1);
|
||||
totalHoursThisMonth += a.hoursPerDay * days;
|
||||
totalCostCentsThisMonth += a.dailyCostCents * days;
|
||||
if (a.project?.id) activeProjectIds.add(a.project.id);
|
||||
}
|
||||
|
||||
const avgDailyCost =
|
||||
thisMonthAllocs.length > 0
|
||||
? Math.round(totalCostCentsThisMonth / 100 / (thisMonthAllocs.length || 1))
|
||||
: 0;
|
||||
|
||||
// Filter upcoming/active allocations (not cancelled, ending >= today)
|
||||
const upcomingAllocations = (allocations ?? []).filter(
|
||||
(a) => a.status !== "CANCELLED" && new Date(a.endDate) >= now,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Back navigation */}
|
||||
<Link
|
||||
href="/resources"
|
||||
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Resources
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-2xl font-bold text-gray-900 truncate">{resource.displayName}</h1>
|
||||
<span
|
||||
className={`flex-shrink-0 px-2.5 py-0.5 text-xs font-medium rounded-full ${
|
||||
resource.isActive ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{resource.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<span className="font-mono">{resource.eid}</span>
|
||||
{" · "}
|
||||
<a href={`mailto:${resource.email}`} className="hover:underline">{resource.email}</a>
|
||||
{resource.chapter && (
|
||||
<>
|
||||
{" · "}
|
||||
<span>{resource.chapter}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canUpload && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadOpen(true)}
|
||||
className="flex-shrink-0 flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
Update Skill Matrix
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="flex-shrink-0 flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{canViewCosts && (
|
||||
<StatCard
|
||||
label="LCR"
|
||||
value={`${(resource.lcrCents / 100).toFixed(0)} ${resource.currency}/h`}
|
||||
/>
|
||||
)}
|
||||
{canViewCosts && (
|
||||
<StatCard
|
||||
label="UCR"
|
||||
value={`${(resource.ucrCents / 100).toFixed(0)} ${resource.currency}/h`}
|
||||
/>
|
||||
)}
|
||||
<StatCard
|
||||
label="Chargeability Target"
|
||||
value={`${resource.chargeabilityTarget}%`}
|
||||
/>
|
||||
{canViewCosts && (
|
||||
<StatCard
|
||||
label="Actual (this month)"
|
||||
value={chargeStats != null ? `${chargeStats.actualChargeability}%` : "—"}
|
||||
sub="Excl. draft projects"
|
||||
/>
|
||||
)}
|
||||
{canViewCosts && (
|
||||
<StatCard
|
||||
label="Expected (this month)"
|
||||
value={chargeStats != null ? `${chargeStats.expectedChargeability}%` : "—"}
|
||||
sub="Incl. draft projects"
|
||||
/>
|
||||
)}
|
||||
<StatCard
|
||||
label="Hours This Month"
|
||||
value={`${Math.round(totalHoursThisMonth)}h`}
|
||||
sub={`${activeProjectIds.size} active project${activeProjectIds.size !== 1 ? "s" : ""}`}
|
||||
/>
|
||||
{canViewScores && resourceWithMeta.valueScore != null && (
|
||||
<div className="relative group">
|
||||
<StatCard
|
||||
label="Value Score"
|
||||
value={resourceWithMeta.valueScore}
|
||||
sub="Price/Quality"
|
||||
/>
|
||||
{resourceWithMeta.valueScoreBreakdown && (
|
||||
<div className="absolute left-0 top-full mt-1 z-10 hidden group-hover:block w-56 bg-white rounded-xl border border-gray-200 shadow-lg p-3 text-xs space-y-1.5">
|
||||
<p className="font-semibold text-gray-700 mb-2">Score Breakdown</p>
|
||||
{(
|
||||
[
|
||||
["Skill Depth", resourceWithMeta.valueScoreBreakdown.skillDepth],
|
||||
["Skill Breadth", resourceWithMeta.valueScoreBreakdown.skillBreadth],
|
||||
["Cost Efficiency", resourceWithMeta.valueScoreBreakdown.costEfficiency],
|
||||
["Chargeability", resourceWithMeta.valueScoreBreakdown.chargeability],
|
||||
["Experience", resourceWithMeta.valueScoreBreakdown.experience],
|
||||
] as [string, number][]
|
||||
).map(([label, val]) => (
|
||||
<div key={label}>
|
||||
<div className="flex justify-between text-gray-600 mb-0.5">
|
||||
<span>{label}</span>
|
||||
<span className="font-mono">{val}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${val >= 70 ? "bg-green-500" : val >= 40 ? "bg-amber-400" : "bg-red-400"}`}
|
||||
style={{ width: `${val}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Profile meta (area role, portfolio, last import) */}
|
||||
{(resourceWithMeta.areaRole || resourceWithMeta.portfolioUrl || resourceWithMeta.skillMatrixUpdatedAt) && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 flex flex-wrap gap-4 text-sm">
|
||||
{resourceWithMeta.areaRole && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-500 text-xs">Area:</span>
|
||||
<span className="font-medium text-gray-800">{resourceWithMeta.areaRole.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{resourceWithMeta.portfolioUrl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-500 text-xs">Portfolio:</span>
|
||||
<a
|
||||
href={resourceWithMeta.portfolioUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-600 hover:underline truncate max-w-xs"
|
||||
>
|
||||
{resourceWithMeta.portfolioUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{resourceWithMeta.skillMatrixUpdatedAt && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-500 text-xs">Skill matrix updated:</span>
|
||||
<span className="text-gray-600">{formatDate(resourceWithMeta.skillMatrixUpdatedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Summary */}
|
||||
<AiSummaryCard
|
||||
resourceId={resourceId}
|
||||
aiSummary={resourceWithMeta.aiSummary ?? null}
|
||||
aiSummaryUpdatedAt={resourceWithMeta.aiSummaryUpdatedAt ?? null}
|
||||
onGenerated={async () => { await utils.resource.getById.invalidate({ id: resourceId }); }}
|
||||
/>
|
||||
|
||||
{/* Main Skills Badges */}
|
||||
{mainSkills.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-3">Main Skills</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mainSkills.map((s) => (
|
||||
<span
|
||||
key={s.skill}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-full bg-amber-50 text-amber-800 border border-amber-200"
|
||||
>
|
||||
<span className="text-amber-500">★</span>
|
||||
{s.skill}
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${proficiencyColor[s.proficiency] ?? "bg-gray-100 text-gray-500"}`}>
|
||||
{proficiencyLabel[s.proficiency] ?? `L${s.proficiency}`}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skill Radar Chart */}
|
||||
<SkillRadarChart skills={skills} />
|
||||
|
||||
{/* Roles */}
|
||||
{resourceRoles.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-3">Roles</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{resourceRoles.map((rr) => (
|
||||
<span
|
||||
key={rr.role.id}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${rr.role.color ?? "#6366f1"}22`,
|
||||
color: rr.role.color ?? "#6366f1",
|
||||
}}
|
||||
>
|
||||
{rr.isPrimary && <span className="text-[11px]">★</span>}
|
||||
{rr.role.name}
|
||||
{rr.isPrimary && <span className="text-[10px] opacity-70">Primary</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-3">Skills</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills.map((s) => (
|
||||
<span
|
||||
key={s.skill}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm rounded-full bg-gray-100 text-gray-700"
|
||||
>
|
||||
{s.skill}
|
||||
{s.proficiency != null && (
|
||||
<span
|
||||
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${
|
||||
proficiencyColor[s.proficiency] ?? "bg-gray-100 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{proficiencyLabel[s.proficiency] ?? `L${s.proficiency}`}
|
||||
</span>
|
||||
)}
|
||||
{s.yearsExperience != null && (
|
||||
<span className="text-xs text-gray-400">{s.yearsExperience}y</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active / Upcoming Allocations */}
|
||||
<div className="bg-white rounded-xl border border-gray-200">
|
||||
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800">Active & Upcoming Allocations</h2>
|
||||
<span className="text-xs text-gray-400">Next 90 days</span>
|
||||
</div>
|
||||
{loadingAllocations ? (
|
||||
<div className="p-6 text-center text-gray-400 text-sm animate-pulse">Loading…</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">Project</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">Role</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">Period</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500">h/Day</th>
|
||||
{canViewCosts && <th className="px-4 py-3 text-right text-xs font-medium text-gray-500">Daily Cost</th>}
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{upcomingAllocations.map((a) => {
|
||||
const isOver = a.hoursPerDay > 8;
|
||||
return (
|
||||
<tr key={a.id} className={`hover:bg-gray-50 ${isOver ? "bg-amber-50" : ""}`}>
|
||||
<td className="px-4 py-3">
|
||||
{a.project ? (
|
||||
<>
|
||||
<span className="font-mono text-xs text-gray-500 mr-1">{a.project.shortCode}</span>
|
||||
{a.project.name}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{a.role ?? (a.roleEntity?.name ?? "—")}</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">
|
||||
{formatDate(a.startDate)} → {formatDate(a.endDate)}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-right font-medium ${isOver ? "text-amber-600" : "text-gray-900"}`}>
|
||||
{a.hoursPerDay}h
|
||||
</td>
|
||||
{canViewCosts && (
|
||||
<td className="px-4 py-3 text-right text-gray-700">
|
||||
{a.dailyCostCents > 0
|
||||
? `${(a.dailyCostCents / 100).toFixed(0)}/d`
|
||||
: "—"}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
|
||||
allocationStatusColor[a.status] ?? "bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{a.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{upcomingAllocations.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8 text-gray-400 text-sm">
|
||||
No active or upcoming allocations.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vacations */}
|
||||
<div className="bg-white rounded-xl border border-gray-200">
|
||||
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800">Vacations</h2>
|
||||
<span className="text-xs text-gray-400">Last month + upcoming</span>
|
||||
</div>
|
||||
{loadingVacations ? (
|
||||
<div className="p-6 text-center text-gray-400 text-sm animate-pulse">Loading…</div>
|
||||
) : (vacations ?? []).length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">No vacations recorded.</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{(vacations ?? []).map((v) => {
|
||||
const days =
|
||||
Math.round(
|
||||
(new Date(v.endDate).getTime() - new Date(v.startDate).getTime()) / (1000 * 60 * 60 * 24),
|
||||
) + 1;
|
||||
return (
|
||||
<div key={v.id} className="px-5 py-3 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800">
|
||||
{v.type.replace(/_/g, " ")}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{formatDate(v.startDate)} → {formatDate(v.endDate)}
|
||||
<span className="ml-1 text-gray-400">({days} day{days !== 1 ? "s" : ""})</span>
|
||||
</div>
|
||||
{v.note && (
|
||||
<div className="text-xs text-gray-400 mt-0.5 italic truncate max-w-sm">{v.note}</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`flex-shrink-0 px-2.5 py-0.5 text-xs font-medium rounded-full ${
|
||||
vacationStatusColor[v.status] ?? "bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{v.status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit modal */}
|
||||
{editOpen && (
|
||||
<ResourceModal
|
||||
mode="edit"
|
||||
resource={resource as unknown as Resource}
|
||||
onClose={async () => {
|
||||
setEditOpen(false);
|
||||
await utils.resource.getById.invalidate({ id: resourceId });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Skill Matrix Upload modal */}
|
||||
{uploadOpen && (
|
||||
<SkillMatrixUpload
|
||||
resourceId={resourceId}
|
||||
isOwner={isOwner}
|
||||
onClose={() => setUploadOpen(false)}
|
||||
onSuccess={async () => {
|
||||
setUploadOpen(false);
|
||||
await utils.resource.getById.invalidate({ id: resourceId });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,941 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import type { Resource, SkillEntry } from "@planarchy/shared";
|
||||
import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface RoleAssignment {
|
||||
roleId: string;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
interface SkillRow {
|
||||
skill: string;
|
||||
proficiency: 1 | 2 | 3 | 4 | 5;
|
||||
yearsExperience: string;
|
||||
category: string;
|
||||
certified: boolean;
|
||||
isMainSkill: boolean;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
eid: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
chapter: string;
|
||||
lcrEuros: string;
|
||||
ucrEuros: string;
|
||||
currency: string;
|
||||
chargeabilityTarget: string;
|
||||
monday: string;
|
||||
tuesday: string;
|
||||
wednesday: string;
|
||||
thursday: string;
|
||||
friday: string;
|
||||
skills: SkillRow[];
|
||||
roles: RoleAssignment[];
|
||||
portfolioUrl: string;
|
||||
roleId: string;
|
||||
postalCode: string;
|
||||
federalState: string;
|
||||
countryId: string;
|
||||
metroCityId: string;
|
||||
orgUnitId: string;
|
||||
managementLevelGroupId: string;
|
||||
managementLevelId: string;
|
||||
resourceType: string;
|
||||
chgResponsibility: boolean;
|
||||
enterpriseId: string;
|
||||
clientUnitId: string;
|
||||
fte: string;
|
||||
}
|
||||
|
||||
function resourceToFormState(resource: Resource): FormState {
|
||||
const skills = (resource.skills as SkillEntry[]).map((s) => ({
|
||||
skill: s.skill,
|
||||
proficiency: s.proficiency,
|
||||
yearsExperience: s.yearsExperience != null ? String(s.yearsExperience) : "",
|
||||
category: s.category ?? "",
|
||||
certified: s.certified ?? false,
|
||||
isMainSkill: s.isMainSkill ?? false,
|
||||
}));
|
||||
|
||||
const roles: RoleAssignment[] = (resource.roles ?? []).map((r) => ({
|
||||
roleId: r.roleId,
|
||||
isPrimary: r.isPrimary,
|
||||
}));
|
||||
|
||||
const resourceWithMeta = resource as unknown as {
|
||||
portfolioUrl?: string | null;
|
||||
roleId?: string | null;
|
||||
};
|
||||
|
||||
return {
|
||||
eid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
email: resource.email,
|
||||
chapter: resource.chapter ?? "",
|
||||
lcrEuros: String(resource.lcrCents / 100),
|
||||
ucrEuros: String(resource.ucrCents / 100),
|
||||
currency: resource.currency,
|
||||
chargeabilityTarget: String(resource.chargeabilityTarget),
|
||||
monday: String(resource.availability.monday),
|
||||
tuesday: String(resource.availability.tuesday),
|
||||
wednesday: String(resource.availability.wednesday),
|
||||
thursday: String(resource.availability.thursday),
|
||||
friday: String(resource.availability.friday),
|
||||
skills,
|
||||
roles,
|
||||
portfolioUrl: resourceWithMeta.portfolioUrl ?? "",
|
||||
roleId: resourceWithMeta.roleId ?? "",
|
||||
postalCode: (resource as unknown as { postalCode?: string | null }).postalCode ?? "",
|
||||
federalState: (resource as unknown as { federalState?: string | null }).federalState ?? "",
|
||||
countryId: (resource as unknown as { countryId?: string | null }).countryId ?? "",
|
||||
metroCityId: (resource as unknown as { metroCityId?: string | null }).metroCityId ?? "",
|
||||
orgUnitId: (resource as unknown as { orgUnitId?: string | null }).orgUnitId ?? "",
|
||||
managementLevelGroupId: (resource as unknown as { managementLevelGroupId?: string | null }).managementLevelGroupId ?? "",
|
||||
managementLevelId: (resource as unknown as { managementLevelId?: string | null }).managementLevelId ?? "",
|
||||
resourceType: (resource as unknown as { resourceType?: string }).resourceType ?? "EMPLOYEE",
|
||||
chgResponsibility: (resource as unknown as { chgResponsibility?: boolean }).chgResponsibility ?? true,
|
||||
enterpriseId: (resource as unknown as { enterpriseId?: string | null }).enterpriseId ?? "",
|
||||
clientUnitId: (resource as unknown as { clientUnitId?: string | null }).clientUnitId ?? "",
|
||||
fte: String((resource as unknown as { fte?: number }).fte ?? 1),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultFormState(): FormState {
|
||||
return {
|
||||
eid: "",
|
||||
displayName: "",
|
||||
email: "",
|
||||
chapter: "",
|
||||
lcrEuros: "",
|
||||
ucrEuros: "",
|
||||
currency: "EUR",
|
||||
chargeabilityTarget: "80",
|
||||
monday: "8",
|
||||
tuesday: "8",
|
||||
wednesday: "8",
|
||||
thursday: "8",
|
||||
friday: "8",
|
||||
skills: [],
|
||||
roles: [],
|
||||
portfolioUrl: "",
|
||||
roleId: "",
|
||||
postalCode: "",
|
||||
federalState: "",
|
||||
countryId: "",
|
||||
metroCityId: "",
|
||||
orgUnitId: "",
|
||||
managementLevelGroupId: "",
|
||||
managementLevelId: "",
|
||||
resourceType: "EMPLOYEE",
|
||||
chgResponsibility: true,
|
||||
enterpriseId: "",
|
||||
clientUnitId: "",
|
||||
fte: "1",
|
||||
};
|
||||
}
|
||||
|
||||
function defaultSkillRow(): SkillRow {
|
||||
return { skill: "", proficiency: 3, yearsExperience: "", category: "", certified: false, isMainSkill: false };
|
||||
}
|
||||
|
||||
interface ResourceModalProps {
|
||||
mode: "create" | "edit";
|
||||
resource?: Resource;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const INPUT_CLASS =
|
||||
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100";
|
||||
const LABEL_CLASS = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||
const SECTION_HEADER_CLASS = "text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-4";
|
||||
const PRIMARY_BTN =
|
||||
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 text-white inline-block mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
||||
const [form, setForm] = useState<FormState>(() =>
|
||||
resource ? resourceToFormState(resource) : defaultFormState(),
|
||||
);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: availableRoles } = trpc.role.list.useQuery(
|
||||
{ isActive: true },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const { data: countries } = trpc.country.list.useQuery(undefined, { staleTime: 60_000 });
|
||||
const { data: orgUnits } = trpc.orgUnit.list.useQuery(undefined, { staleTime: 60_000 });
|
||||
const { data: mgmtGroups } = trpc.managementLevel.listGroups.useQuery(undefined, { staleTime: 60_000 });
|
||||
const { data: clients } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
|
||||
|
||||
// Derive metro cities from selected country
|
||||
const selectedCountry = (countries ?? []).find((c) => c.id === form.countryId) as unknown as { id: string; metroCities: { id: string; name: string }[] } | undefined;
|
||||
const metroCities = selectedCountry?.metroCities ?? [];
|
||||
|
||||
// Derive levels from selected group
|
||||
const selectedGroup = (mgmtGroups ?? []).find((g) => g.id === form.managementLevelGroupId) as unknown as { id: string; levels: { id: string; name: string }[] } | undefined;
|
||||
const mgmtLevels = selectedGroup?.levels ?? [];
|
||||
|
||||
const createMutation = trpc.resource.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.resource.list.invalidate();
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setErrorMsg(err.message ?? "An error occurred while saving.");
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = trpc.resource.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.resource.list.invalidate();
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setErrorMsg(err.message ?? "An error occurred while saving.");
|
||||
},
|
||||
});
|
||||
|
||||
const isMutating = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
setErrorMsg(null);
|
||||
}
|
||||
|
||||
function setSkillField(index: number, key: keyof SkillRow, value: string | number | boolean) {
|
||||
setForm((prev) => {
|
||||
const skills = prev.skills.map((s, i) => (i === index ? { ...s, [key]: value } : s));
|
||||
return { ...prev, skills };
|
||||
});
|
||||
}
|
||||
|
||||
function addSkill() {
|
||||
setForm((prev) => ({ ...prev, skills: [...prev.skills, defaultSkillRow()] }));
|
||||
}
|
||||
|
||||
function removeSkill(index: number) {
|
||||
setForm((prev) => ({ ...prev, skills: prev.skills.filter((_, i) => i !== index) }));
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
const lcrCents = Math.round(parseFloat(form.lcrEuros || "0") * 100);
|
||||
const ucrCents = Math.round(parseFloat(form.ucrEuros || "0") * 100);
|
||||
const chargeabilityTarget = parseFloat(form.chargeabilityTarget || "80");
|
||||
|
||||
const mainSkillCount = form.skills.filter((s) => s.isMainSkill).length;
|
||||
const skills = form.skills
|
||||
.filter((s) => s.skill.trim() !== "")
|
||||
.map((s) => ({
|
||||
skill: s.skill.trim(),
|
||||
proficiency: s.proficiency,
|
||||
...(s.yearsExperience !== "" ? { yearsExperience: parseFloat(s.yearsExperience) } : {}),
|
||||
...(s.category.trim() !== "" ? { category: s.category.trim() } : {}),
|
||||
...(s.certified ? { certified: s.certified } : {}),
|
||||
...(s.isMainSkill ? { isMainSkill: true } : {}),
|
||||
}));
|
||||
|
||||
void mainSkillCount; // used for UI validation only
|
||||
|
||||
return {
|
||||
eid: form.eid.trim(),
|
||||
displayName: form.displayName.trim(),
|
||||
email: form.email.trim(),
|
||||
...(form.chapter.trim() !== "" ? { chapter: form.chapter.trim() } : {}),
|
||||
lcrCents,
|
||||
ucrCents,
|
||||
currency: form.currency,
|
||||
chargeabilityTarget,
|
||||
availability: {
|
||||
monday: parseFloat(form.monday || "8"),
|
||||
tuesday: parseFloat(form.tuesday || "8"),
|
||||
wednesday: parseFloat(form.wednesday || "8"),
|
||||
thursday: parseFloat(form.thursday || "8"),
|
||||
friday: parseFloat(form.friday || "8"),
|
||||
},
|
||||
skills,
|
||||
roles: form.roles,
|
||||
...(form.portfolioUrl.trim() !== "" ? { portfolioUrl: form.portfolioUrl.trim() } : {}),
|
||||
...(form.roleId.trim() !== "" ? { roleId: form.roleId.trim() } : {}),
|
||||
...(form.postalCode.trim() !== "" ? { postalCode: form.postalCode.trim() } : {}),
|
||||
...(form.federalState.trim() !== "" ? { federalState: form.federalState.trim() } : {}),
|
||||
...(form.countryId ? { countryId: form.countryId } : {}),
|
||||
...(form.metroCityId ? { metroCityId: form.metroCityId } : {}),
|
||||
...(form.orgUnitId ? { orgUnitId: form.orgUnitId } : {}),
|
||||
...(form.managementLevelGroupId ? { managementLevelGroupId: form.managementLevelGroupId } : {}),
|
||||
...(form.managementLevelId ? { managementLevelId: form.managementLevelId } : {}),
|
||||
resourceType: form.resourceType as ResourceType,
|
||||
chgResponsibility: form.chgResponsibility,
|
||||
...(form.enterpriseId.trim() !== "" ? { enterpriseId: form.enterpriseId.trim() } : {}),
|
||||
...(form.clientUnitId ? { clientUnitId: form.clientUnitId } : {}),
|
||||
fte: parseFloat(form.fte) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setErrorMsg(null);
|
||||
|
||||
const payload = buildPayload();
|
||||
|
||||
if (mode === "create") {
|
||||
createMutation.mutate(payload);
|
||||
} else if (resource) {
|
||||
updateMutation.mutate({ id: resource.id, data: payload });
|
||||
}
|
||||
}
|
||||
|
||||
const proficiencyLabels: Record<number, string> = {
|
||||
1: "1 – Beginner",
|
||||
2: "2 – Elementary",
|
||||
3: "3 – Intermediate",
|
||||
4: "4 – Advanced",
|
||||
5: "5 – Expert",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{mode === "create" ? "New Resource" : "Edit Resource"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<div className="px-6 pb-4">
|
||||
{/* Section 1: Basic Info */}
|
||||
<p className={SECTION_HEADER_CLASS}>Basic Info</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-eid">
|
||||
Employee ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="rm-eid"
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="EMP-042"
|
||||
value={form.eid}
|
||||
onChange={(e) => setField("eid", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-displayName">
|
||||
Display Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="rm-displayName"
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="Jane Smith"
|
||||
value={form.displayName}
|
||||
onChange={(e) => setField("displayName", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-email">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="rm-email"
|
||||
type="email"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="jane@example.com"
|
||||
value={form.email}
|
||||
onChange={(e) => setField("email", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-chapter">
|
||||
Chapter <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="rm-chapter"
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="Engineering"
|
||||
value={form.chapter}
|
||||
onChange={(e) => setField("chapter", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Portfolio & Role */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-portfolioUrl">
|
||||
Portfolio URL <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="rm-portfolioUrl"
|
||||
type="url"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="https://artstation.com/…"
|
||||
value={form.portfolioUrl}
|
||||
onChange={(e) => setField("portfolioUrl", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-roleId">
|
||||
Area of Expertise <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
|
||||
</label>
|
||||
<select
|
||||
id="rm-roleId"
|
||||
className={INPUT_CLASS}
|
||||
value={form.roleId}
|
||||
onChange={(e) => setField("roleId", e.target.value)}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{(availableRoles ?? []).map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Postal Code & Federal State */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-postalCode">
|
||||
Postal Code (PLZ) <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="rm-postalCode"
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="80331"
|
||||
maxLength={5}
|
||||
value={form.postalCode}
|
||||
onChange={(e) => {
|
||||
const plz = e.target.value;
|
||||
setField("postalCode", plz);
|
||||
if (/^\d{5}$/.test(plz)) {
|
||||
const inferred = inferStateFromPostalCode(plz);
|
||||
if (inferred && !form.federalState) {
|
||||
setField("federalState", inferred);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-federalState">
|
||||
Federal State <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
|
||||
</label>
|
||||
<select
|
||||
id="rm-federalState"
|
||||
className={INPUT_CLASS}
|
||||
value={form.federalState}
|
||||
onChange={(e) => setField("federalState", e.target.value)}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{Object.entries(GERMAN_FEDERAL_STATES).map(([abbr, name]) => (
|
||||
<option key={abbr} value={abbr}>{name} ({abbr})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section: Organization & Classification */}
|
||||
<p className={SECTION_HEADER_CLASS}>Organization & Classification</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-enterpriseId">
|
||||
Enterprise ID <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="rm-enterpriseId"
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="a.kasperovich"
|
||||
value={form.enterpriseId}
|
||||
onChange={(e) => setField("enterpriseId", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-fte">
|
||||
FTE
|
||||
</label>
|
||||
<input
|
||||
id="rm-fte"
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="1"
|
||||
step="0.01"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="1.0"
|
||||
value={form.fte}
|
||||
onChange={(e) => setField("fte", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-countryId">Country</label>
|
||||
<select
|
||||
id="rm-countryId"
|
||||
className={INPUT_CLASS}
|
||||
value={form.countryId}
|
||||
onChange={(e) => {
|
||||
setField("countryId", e.target.value);
|
||||
setField("metroCityId", ""); // reset city when country changes
|
||||
}}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{(countries ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>{(c as unknown as { name: string }).name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-metroCityId">Metro City</label>
|
||||
<select
|
||||
id="rm-metroCityId"
|
||||
className={INPUT_CLASS}
|
||||
value={form.metroCityId}
|
||||
onChange={(e) => setField("metroCityId", e.target.value)}
|
||||
disabled={!form.countryId}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{metroCities.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-orgUnitId">Org Unit (L7 Team)</label>
|
||||
<select
|
||||
id="rm-orgUnitId"
|
||||
className={INPUT_CLASS}
|
||||
value={form.orgUnitId}
|
||||
onChange={(e) => setField("orgUnitId", e.target.value)}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{(orgUnits ?? [])
|
||||
.filter((u) => (u as unknown as { level: number }).level === 7 && (u as unknown as { isActive: boolean }).isActive)
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id}>{(u as unknown as { name: string }).name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-clientUnitId">Client Unit</label>
|
||||
<select
|
||||
id="rm-clientUnitId"
|
||||
className={INPUT_CLASS}
|
||||
value={form.clientUnitId}
|
||||
onChange={(e) => setField("clientUnitId", e.target.value)}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{(clients ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>{(c as unknown as { name: string }).name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-mgmtGroupId">Management Level Group</label>
|
||||
<select
|
||||
id="rm-mgmtGroupId"
|
||||
className={INPUT_CLASS}
|
||||
value={form.managementLevelGroupId}
|
||||
onChange={(e) => {
|
||||
setField("managementLevelGroupId", e.target.value);
|
||||
setField("managementLevelId", ""); // reset level when group changes
|
||||
}}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{(mgmtGroups ?? []).map((g) => (
|
||||
<option key={g.id} value={g.id}>{(g as unknown as { name: string }).name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-mgmtLevelId">Management Level</label>
|
||||
<select
|
||||
id="rm-mgmtLevelId"
|
||||
className={INPUT_CLASS}
|
||||
value={form.managementLevelId}
|
||||
onChange={(e) => setField("managementLevelId", e.target.value)}
|
||||
disabled={!form.managementLevelGroupId}
|
||||
>
|
||||
<option value="">— Not specified —</option>
|
||||
{mgmtLevels.map((l) => (
|
||||
<option key={l.id} value={l.id}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mt-4">
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-resourceType">Resource Type</label>
|
||||
<select
|
||||
id="rm-resourceType"
|
||||
className={INPUT_CLASS}
|
||||
value={form.resourceType}
|
||||
onChange={(e) => setField("resourceType", e.target.value)}
|
||||
>
|
||||
{Object.values(ResourceType).map((t) => (
|
||||
<option key={t} value={t}>{t.charAt(0) + t.slice(1).toLowerCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.chgResponsibility}
|
||||
onChange={(e) => setField("chgResponsibility", e.target.checked)}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Chg Responsibility
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Cost & Chargeability */}
|
||||
<p className={SECTION_HEADER_CLASS}>Cost & Chargeability</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-lcr">
|
||||
LCR €/h <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="rm-lcr"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="80"
|
||||
value={form.lcrEuros}
|
||||
onChange={(e) => setField("lcrEuros", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-ucr">
|
||||
UCR €/h <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="rm-ucr"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="120"
|
||||
value={form.ucrEuros}
|
||||
onChange={(e) => setField("ucrEuros", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-currency">
|
||||
Currency
|
||||
</label>
|
||||
<select
|
||||
id="rm-currency"
|
||||
className={INPUT_CLASS}
|
||||
value={form.currency}
|
||||
onChange={(e) => setField("currency", e.target.value)}
|
||||
>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="GBP">GBP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="rm-chargeability">
|
||||
Chargeability Target %
|
||||
</label>
|
||||
<input
|
||||
id="rm-chargeability"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="80"
|
||||
value={form.chargeabilityTarget}
|
||||
onChange={(e) => setField("chargeabilityTarget", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: Weekly Availability */}
|
||||
<p className={SECTION_HEADER_CLASS}>Weekly Availability (hours/day)</p>
|
||||
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{(
|
||||
[
|
||||
["monday", "Mon"],
|
||||
["tuesday", "Tue"],
|
||||
["wednesday", "Wed"],
|
||||
["thursday", "Thu"],
|
||||
["friday", "Fri"],
|
||||
] as const
|
||||
).map(([day, label]) => (
|
||||
<div key={day}>
|
||||
<label className={LABEL_CLASS} htmlFor={`rm-${day}`}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={`rm-${day}`}
|
||||
type="number"
|
||||
min="0"
|
||||
max="24"
|
||||
step="0.5"
|
||||
className={INPUT_CLASS}
|
||||
value={form[day]}
|
||||
onChange={(e) => setField(day, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Section 4: Skills */}
|
||||
<p className={SECTION_HEADER_CLASS}>Skills</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{form.skills.map((skillRow, idx) => {
|
||||
const mainSkillCount = form.skills.filter((s) => s.isMainSkill).length;
|
||||
const canToggleMain = skillRow.isMainSkill || mainSkillCount < 2;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`grid gap-2 items-end border rounded-lg p-3 ${skillRow.isMainSkill ? "border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20" : "border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"}`}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-2 items-end">
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor={`rm-skill-name-${idx}`}>
|
||||
Skill
|
||||
</label>
|
||||
<input
|
||||
id={`rm-skill-name-${idx}`}
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="e.g. 3ds Max"
|
||||
value={skillRow.skill}
|
||||
onChange={(e) => setSkillField(idx, "skill", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor={`rm-skill-prof-${idx}`}>
|
||||
Proficiency
|
||||
</label>
|
||||
<select
|
||||
id={`rm-skill-prof-${idx}`}
|
||||
className={INPUT_CLASS}
|
||||
value={skillRow.proficiency}
|
||||
onChange={(e) =>
|
||||
setSkillField(idx, "proficiency", parseInt(e.target.value, 10) as 1 | 2 | 3 | 4 | 5)
|
||||
}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{proficiencyLabels[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor={`rm-skill-years-${idx}`}>
|
||||
Years
|
||||
</label>
|
||||
<input
|
||||
id={`rm-skill-years-${idx}`}
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
step="1"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="—"
|
||||
value={skillRow.yearsExperience}
|
||||
onChange={(e) => setSkillField(idx, "yearsExperience", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 pb-0.5">
|
||||
<span className="text-[10px] text-gray-500 dark:text-gray-400 leading-none">★ Main</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skillRow.isMainSkill}
|
||||
disabled={!canToggleMain}
|
||||
title={!canToggleMain ? "Max 2 main skills" : "Mark as main skill"}
|
||||
onChange={(e) => setSkillField(idx, "isMainSkill", e.target.checked)}
|
||||
className="rounded border-gray-300 disabled:opacity-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end pb-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSkill(idx)}
|
||||
className="px-2 py-2 text-red-400 hover:text-red-600 transition-colors"
|
||||
aria-label={`Remove skill ${idx + 1}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSkill}
|
||||
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add skill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section 5: Roles */}
|
||||
<p className={SECTION_HEADER_CLASS}>Roles</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(availableRoles ?? []).map((role) => {
|
||||
const assignment = form.roles.find((r) => r.roleId === role.id);
|
||||
const isChecked = Boolean(assignment);
|
||||
|
||||
return (
|
||||
<div key={role.id} className="flex items-center gap-3 py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`role-${role.id}`}
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setField("roles", [...form.roles, { roleId: role.id, isPrimary: false }]);
|
||||
} else {
|
||||
setField("roles", form.roles.filter((r) => r.roleId !== role.id));
|
||||
}
|
||||
}}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: role.color ?? "#6366f1" }}
|
||||
/>
|
||||
<label htmlFor={`role-${role.id}`} className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer flex-1">
|
||||
{role.name}
|
||||
</label>
|
||||
{isChecked && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="primary-role"
|
||||
checked={assignment?.isPrimary ?? false}
|
||||
onChange={() => {
|
||||
setField("roles", form.roles.map((r) =>
|
||||
r.roleId === role.id
|
||||
? { ...r, isPrimary: true }
|
||||
: { ...r, isPrimary: false },
|
||||
));
|
||||
}}
|
||||
className="border-gray-300"
|
||||
/>
|
||||
Primary
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(availableRoles ?? []).length === 0 && (
|
||||
<p className="text-sm text-gray-400 italic">No roles defined yet. Create roles on the Roles page.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{errorMsg && (
|
||||
<div className="mt-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isMutating}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={isMutating} className={PRIMARY_BTN}>
|
||||
{isMutating && <Spinner />}
|
||||
{isMutating ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
|
||||
import type { SkillEntry } from "@planarchy/shared";
|
||||
|
||||
interface Props {
|
||||
resourceId: string;
|
||||
isOwner: boolean; // true = self-service, false = manager import
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
type PreviewState = {
|
||||
skills: SkillEntry[];
|
||||
employeeInfo: {
|
||||
roleId?: string;
|
||||
portfolioUrl?: string;
|
||||
};
|
||||
matchedRoleName?: string;
|
||||
};
|
||||
|
||||
export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: Props) {
|
||||
const [preview, setPreview] = useState<PreviewState | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: roles } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
||||
|
||||
const selfMutation = trpc.resource.importSkillMatrix.useMutation({
|
||||
onSuccess: () => { setSubmitting(false); onSuccess(); },
|
||||
onError: (err) => { setSubmitting(false); setParseError(err.message); },
|
||||
});
|
||||
|
||||
const managerMutation = trpc.resource.importSkillMatrixForResource.useMutation({
|
||||
onSuccess: () => { setSubmitting(false); onSuccess(); },
|
||||
onError: (err) => { setSubmitting(false); setParseError(err.message); },
|
||||
});
|
||||
|
||||
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setParseError(null);
|
||||
setPreview(null);
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const parsed = parseSkillMatrixWorkbook(buffer);
|
||||
|
||||
// Fuzzy match areaOfExpertise → roleId
|
||||
let roleId: string | undefined;
|
||||
let matchedRoleName: string | undefined;
|
||||
if (parsed.employeeInfo.areaOfExpertise && roles) {
|
||||
const roleNames = roles.map((r) => r.name);
|
||||
const matched = matchRoleName(parsed.employeeInfo.areaOfExpertise, roleNames);
|
||||
if (matched) {
|
||||
const role = roles.find((r) => r.name === matched);
|
||||
roleId = role?.id;
|
||||
matchedRoleName = matched;
|
||||
}
|
||||
}
|
||||
|
||||
setPreview({
|
||||
skills: parsed.skills,
|
||||
employeeInfo: {
|
||||
...(roleId !== undefined ? { roleId } : {}),
|
||||
...(parsed.employeeInfo.portfolioUrl !== undefined ? { portfolioUrl: parsed.employeeInfo.portfolioUrl } : {}),
|
||||
},
|
||||
...(matchedRoleName !== undefined ? { matchedRoleName } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
setParseError(String(err instanceof Error ? err.message : err));
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!preview) return;
|
||||
setSubmitting(true);
|
||||
|
||||
const payload = {
|
||||
skills: preview.skills,
|
||||
employeeInfo: {
|
||||
roleId: preview.employeeInfo.roleId,
|
||||
portfolioUrl: preview.employeeInfo.portfolioUrl,
|
||||
},
|
||||
};
|
||||
|
||||
if (isOwner) {
|
||||
selfMutation.mutate(payload);
|
||||
} else {
|
||||
managerMutation.mutate({ resourceId, ...payload });
|
||||
}
|
||||
}
|
||||
|
||||
const mainSkills = preview?.skills.filter((s) => s.isMainSkill) ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-base font-semibold text-gray-900">Update Skill Matrix</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
{/* File picker */}
|
||||
{!preview && (
|
||||
<div
|
||||
className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<svg className="w-10 h-10 text-gray-300 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="text-sm font-medium text-gray-700">Click to select skill matrix file</p>
|
||||
<p className="text-xs text-gray-400 mt-1">.xlsx accepted</p>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseError && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
|
||||
{parseError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{preview && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-brand-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-brand-700">{preview.skills.length}</div>
|
||||
<div className="text-xs text-brand-600 mt-0.5">Skills found</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-green-700">{mainSkills.length}</div>
|
||||
<div className="text-xs text-green-600 mt-0.5">Main skills</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mainSkills.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1.5">Main skills:</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{mainSkills.map((s) => (
|
||||
<span key={s.skill} className="px-2.5 py-0.5 text-xs font-medium rounded-full bg-amber-50 text-amber-700 border border-amber-200">
|
||||
★ {s.skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.matchedRoleName && (
|
||||
<p className="text-xs text-gray-600">
|
||||
<span className="font-medium">Area of expertise</span> matched to Planarchy role:{" "}
|
||||
<span className="font-semibold text-brand-700">{preview.matchedRoleName}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview.employeeInfo.portfolioUrl && (
|
||||
<p className="text-xs text-gray-600 truncate">
|
||||
<span className="font-medium">Portfolio URL:</span>{" "}
|
||||
<a href={preview.employeeInfo.portfolioUrl} target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline">
|
||||
{preview.employeeInfo.portfolioUrl}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
This will <strong>replace all existing skills</strong> for this resource.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setPreview(null); setParseError(null); if (fileRef.current) fileRef.current.value = ""; }}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 underline"
|
||||
>
|
||||
Choose a different file
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={!preview || submitting}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Importing…" : "Confirm Import"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import {
|
||||
RadarChart,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
Radar,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
import type { SkillEntry } from "@planarchy/shared";
|
||||
|
||||
interface Props {
|
||||
skills: SkillEntry[];
|
||||
}
|
||||
|
||||
export function SkillRadarChart({ skills }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
if (entry) setWidth(entry.contentRect.width);
|
||||
});
|
||||
ro.observe(el);
|
||||
setWidth(el.getBoundingClientRect().width);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
if (skills.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-4">Skill Profile</h2>
|
||||
<div className="flex items-center justify-center h-48 text-sm text-gray-400 dark:text-gray-500">
|
||||
No skills recorded yet
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const categoryMap = new Map<string, number[]>();
|
||||
for (const s of skills) {
|
||||
const cat = s.category ?? "Other";
|
||||
if (!categoryMap.has(cat)) categoryMap.set(cat, []);
|
||||
categoryMap.get(cat)!.push(s.proficiency);
|
||||
}
|
||||
|
||||
const data = Array.from(categoryMap.entries())
|
||||
.map(([category, profs]) => ({
|
||||
category,
|
||||
score: Math.round((profs.reduce((a, b) => a + b, 0) / profs.length) * 20),
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 8);
|
||||
|
||||
if (data.length < 3) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-4">Skill Profile</h2>
|
||||
<div ref={containerRef} style={{ height: 260 }}>
|
||||
{width > 0 && (
|
||||
<RadarChart
|
||||
width={width}
|
||||
height={260}
|
||||
data={data}
|
||||
margin={{ top: 10, right: 30, bottom: 10, left: 30 }}
|
||||
>
|
||||
<PolarGrid stroke="#e5e7eb" />
|
||||
<PolarAngleAxis
|
||||
dataKey="category"
|
||||
tick={{ fontSize: 11, fill: "#6b7280" }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined) => [`${value ?? 0}%`, "Avg proficiency"] as [string, string]}
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8 }}
|
||||
/>
|
||||
<Radar
|
||||
name="Skills"
|
||||
dataKey="score"
|
||||
stroke="#6366f1"
|
||||
fill="#6366f1"
|
||||
fillOpacity={0.2}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</RadarChart>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import type { RoleWithResourceCount } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
|
||||
const PRESET_COLORS = [
|
||||
"#6366f1", "#8b5cf6", "#ec4899", "#ef4444", "#f97316",
|
||||
"#eab308", "#22c55e", "#14b8a6", "#06b6d4", "#3b82f6",
|
||||
];
|
||||
|
||||
interface RoleModalProps {
|
||||
role?: RoleWithResourceCount | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
|
||||
const isEditing = Boolean(role);
|
||||
|
||||
const [name, setName] = useState(role?.name ?? "");
|
||||
const [description, setDescription] = useState(role?.description ?? "");
|
||||
const [color, setColor] = useState(role?.color ?? PRESET_COLORS[0]!);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createMutation = trpc.role.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.role.list.invalidate();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err) => setServerError(err.message),
|
||||
});
|
||||
|
||||
const updateMutation = trpc.role.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.role.list.invalidate();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err) => setServerError(err.message),
|
||||
});
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setServerError(null);
|
||||
|
||||
if (!name.trim()) {
|
||||
setServerError("Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
color: color || undefined,
|
||||
};
|
||||
|
||||
if (isEditing && role) {
|
||||
updateMutation.mutate({ id: role.id, data: payload });
|
||||
} else {
|
||||
createMutation.mutate(payload);
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
|
||||
const labelClass = "block text-sm font-medium text-gray-700 mb-1";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{isEditing ? "Edit Role" : "New Role"}
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>Name <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setServerError(null); }}
|
||||
placeholder="e.g. 3D Artist"
|
||||
className={inputClass}
|
||||
maxLength={100}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
className={inputClass}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Color</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-gray-200 flex-shrink-0" style={{ backgroundColor: color }} />
|
||||
<div className="flex flex-wrap gap-2 flex-1">
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
borderColor: color === c ? "#1f2937" : "transparent",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-8 h-8 rounded cursor-pointer border border-gray-300"
|
||||
title="Custom color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{serverError && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
|
||||
{serverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} disabled={isPending} className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 disabled:opacity-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={isPending} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50">
|
||||
{isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { RoleWithResourceCount } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { RoleModal } from "./RoleModal.js";
|
||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
|
||||
export function RolesClient() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editRole, setEditRole] = useState<RoleWithResourceCount | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<RoleWithResourceCount | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: roles, isLoading } = trpc.role.list.useQuery(
|
||||
{ isActive: showInactive ? undefined : true, search: search || undefined },
|
||||
{ placeholderData: (prev) => prev, staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const deactivateMutation = trpc.role.deactivate.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.role.list.invalidate();
|
||||
},
|
||||
onError: (err) => setActionError(err.message),
|
||||
});
|
||||
|
||||
const activateMutation = trpc.role.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.role.list.invalidate();
|
||||
},
|
||||
onError: (err) => setActionError(err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.role.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.role.list.invalidate();
|
||||
setConfirmDelete(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err.message);
|
||||
setConfirmDelete(null);
|
||||
},
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
setEditRole(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(role: RoleWithResourceCount) {
|
||||
setEditRole(role);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
const roleList = (roles ?? []) as unknown as RoleWithResourceCount[];
|
||||
|
||||
const rolesViewPrefs = useViewPrefs("roles");
|
||||
const { sorted, sortField, sortDir, toggle } = useTableSort(roleList, {
|
||||
initialField: rolesViewPrefs.savedSort?.field ?? null,
|
||||
initialDir: rolesViewPrefs.savedSort?.dir ?? null,
|
||||
onSortChange: (field, dir) => {
|
||||
rolesViewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
||||
},
|
||||
});
|
||||
|
||||
function clearAll() {
|
||||
setSearch("");
|
||||
setShowInactive(false);
|
||||
}
|
||||
|
||||
const chips = [
|
||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
|
||||
...(showInactive ? [{ label: "Inactive included", onRemove: () => setShowInactive(false) }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Roles</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage role definitions and resource assignments
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||||
>
|
||||
+ New Role
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search roles…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Show inactive
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{chips.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<FilterChips chips={chips} onClearAll={clearAll} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionError && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 flex items-center justify-between">
|
||||
{actionError}
|
||||
<button type="button" onClick={() => setActionError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={toggle} />
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Description</th>
|
||||
<SortableColumnHeader label="Resources" field="resourceRoles" sortField={sortField} sortDir={sortDir} onSort={(f) => toggle(f, (r) => r._count.resourceRoles)} align="center" tooltip="Number of resources that currently have this role assigned (active assignments only)." />
|
||||
<SortableColumnHeader label="Allocations" field="allocations" sortField={sortField} sortDir={sortDir} onSort={(f) => toggle(f, (r) => r._count.allocations)} align="center" tooltip="Total number of planning entries that use this role, including open-demand compatibility rows." />
|
||||
<SortableColumnHeader label="Status" field="isActive" sortField={sortField} sortDir={sortDir} onSort={(f) => toggle(f, (r) => (r.isActive ? 0 : 1))} align="center" tooltip="Active roles are available for assignment. Inactive roles are hidden from pickers but existing assignments remain." />
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8 text-gray-400">Loading…</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && sorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8 text-gray-400">
|
||||
No roles found. Create one to get started.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{sorted.map((role) => (
|
||||
<tr
|
||||
key={role.id}
|
||||
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: role.color ?? "#6366f1" }}
|
||||
/>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{role.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 max-w-xs truncate">
|
||||
{role.description ?? <span className="italic text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center w-8 h-6 rounded bg-gray-100 dark:bg-gray-800 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{role._count.resourceRoles}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center w-8 h-6 rounded bg-gray-100 dark:bg-gray-800 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{role._count.allocations}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
role.isActive
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400"
|
||||
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
|
||||
}`}>
|
||||
{role.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(role)}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{role.isActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActionError(null);
|
||||
deactivateMutation.mutate({ id: role.id });
|
||||
}}
|
||||
disabled={deactivateMutation.isPending}
|
||||
className="text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Deactivate
|
||||
</button>
|
||||
)}
|
||||
{!role.isActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActionError(null);
|
||||
activateMutation.mutate({ id: role.id, data: { isActive: true } });
|
||||
}}
|
||||
disabled={activateMutation.isPending}
|
||||
className="text-xs text-green-600 hover:text-green-800 font-medium"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setConfirmDelete(role); setActionError(null); }}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{modalOpen && (
|
||||
<RoleModal
|
||||
role={editRole}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSuccess={() => setModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmDelete && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-sm mx-4 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Delete Role</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Are you sure you want to delete <strong>{confirmDelete.name}</strong>?
|
||||
{(confirmDelete._count.resourceRoles > 0 || confirmDelete._count.allocations > 0) && (
|
||||
<span className="block mt-1 text-amber-600">
|
||||
This role is assigned to {confirmDelete._count.resourceRoles} resource(s) and {confirmDelete._count.allocations} allocation(s). Deletion will be blocked.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button type="button" onClick={() => setConfirmDelete(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteMutation.mutate({ id: confirmDelete.id })}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{deleteMutation.isPending ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
|
||||
|
||||
export function StaffingPanel() {
|
||||
const [requiredSkills, setRequiredSkills] = useState<string[]>(["TypeScript", "React"]);
|
||||
const [startDate, setStartDate] = useState(new Date().toISOString().split("T")[0] ?? "");
|
||||
const [endDate, setEndDate] = useState(
|
||||
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split("T")[0] ?? "",
|
||||
);
|
||||
const [hoursPerDay, setHoursPerDay] = useState(8);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const { data: suggestions, isLoading } = trpc.staffing.getSuggestions.useQuery(
|
||||
{
|
||||
requiredSkills: requiredSkills,
|
||||
startDate: new Date(startDate),
|
||||
endDate: new Date(endDate),
|
||||
hoursPerDay,
|
||||
},
|
||||
{ enabled: submitted },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Search Form */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Search Criteria</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Required Skills
|
||||
</label>
|
||||
<SkillTagInput
|
||||
value={requiredSkills}
|
||||
onChange={setRequiredSkills}
|
||||
placeholder="Add skill…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
|
||||
<DateInput
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">End Date</label>
|
||||
<DateInput
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Hours per Day</label>
|
||||
<input
|
||||
type="number"
|
||||
value={hoursPerDay}
|
||||
onChange={(e) => setHoursPerDay(Number(e.target.value))}
|
||||
min={1}
|
||||
max={24}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setSubmitted(true)}
|
||||
className="w-full bg-brand-600 hover:bg-brand-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Find Matches
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="lg:col-span-2">
|
||||
{isLoading && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400">
|
||||
Finding best matches...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions && suggestions.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400">
|
||||
No resources found matching your criteria.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions && suggestions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{suggestions.map((suggestion, idx) => (
|
||||
<div key={suggestion.resourceId} className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center font-bold text-brand-700">
|
||||
#{idx + 1}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900">{suggestion.resourceName}</div>
|
||||
<div className="text-xs text-gray-500">{suggestion.eid}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-brand-600">{suggestion.score}</div>
|
||||
<div className="text-xs text-gray-400">score</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-4 gap-3">
|
||||
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} />
|
||||
<ScoreBar label="Avail." value={suggestion.scoreBreakdown.availabilityScore} />
|
||||
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} />
|
||||
<ScoreBar label="Util." value={suggestion.scoreBreakdown.utilizationScore} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{suggestion.matchedSkills.map((skill) => (
|
||||
<span key={skill} className="px-2 py-0.5 bg-green-50 text-green-700 text-xs rounded-full">
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{suggestion.missingSkills.map((skill) => (
|
||||
<span key={skill} className="px-2 py-0.5 bg-red-50 text-red-600 text-xs rounded-full">
|
||||
{skill} (missing)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>
|
||||
LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} €/h
|
||||
</span>
|
||||
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
|
||||
{suggestion.availabilityConflicts.length > 0 && (
|
||||
<span className="text-yellow-600">⚠ {suggestion.availabilityConflicts.length} conflicts</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!submitted && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 border-dashed p-12 text-center text-gray-300">
|
||||
Fill in the criteria and click "Find Matches" to see staffing suggestions.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreBar({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">{label}</div>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand-500 rounded-full"
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-0.5">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AllocationLike, AllocationReadModel, Assignment } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
|
||||
interface AllocationPopoverProps {
|
||||
allocationId: string;
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
onOpenPanel: (projectId: string) => void;
|
||||
/** Pixel position relative to the viewport */
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
}
|
||||
|
||||
type AllocationPopoverAssignment = Assignment<AllocationLike>;
|
||||
|
||||
export function AllocationPopover({
|
||||
allocationId,
|
||||
projectId,
|
||||
onClose,
|
||||
onOpenPanel,
|
||||
anchorX,
|
||||
anchorY,
|
||||
}: AllocationPopoverProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
|
||||
{ projectId },
|
||||
{ staleTime: 10_000 },
|
||||
) as { data: AllocationReadModel<AllocationLike> | undefined; isLoading: boolean };
|
||||
const allocation = allocationView?.assignments.find((entry) => entry.id === allocationId) as AllocationPopoverAssignment | undefined;
|
||||
|
||||
const [hoursPerDay, setHoursPerDay] = useState<number | null>(null);
|
||||
const [startDate, setStartDate] = useState<string>("");
|
||||
const [endDate, setEndDate] = useState<string>("");
|
||||
const [includeSaturday, setIncludeSaturday] = useState(false);
|
||||
const [role, setRole] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (allocation) {
|
||||
setHoursPerDay(allocation.hoursPerDay);
|
||||
setStartDate(toDateInput(new Date(allocation.startDate)));
|
||||
setEndDate(toDateInput(new Date(allocation.endDate)));
|
||||
const meta = allocation.metadata as { includeSaturday?: boolean } | null;
|
||||
setIncludeSaturday(meta?.includeSaturday ?? false);
|
||||
setRole(allocation.role ?? "");
|
||||
}
|
||||
}, [allocation]);
|
||||
|
||||
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
void utils.allocation.listView.invalidate();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
function toDateInput(d: Date): string {
|
||||
return d.toISOString().split("T")[0] ?? "";
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!allocation || hoursPerDay === null) return;
|
||||
updateMutation.mutate({
|
||||
allocationId: getPlanningEntryMutationId(allocation),
|
||||
hoursPerDay,
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
endDate: endDate ? new Date(endDate) : undefined,
|
||||
includeSaturday,
|
||||
role: role || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Position popover so it stays on screen
|
||||
const popoverStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
left: Math.min(anchorX, window.innerWidth - 320),
|
||||
top: Math.min(anchorY + 8, window.innerHeight - 360),
|
||||
zIndex: 50,
|
||||
width: 300,
|
||||
};
|
||||
|
||||
if (isLoading || !allocation) {
|
||||
return (
|
||||
<div ref={ref} style={popoverStyle} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dailyCostEUR = ((hoursPerDay ?? allocation.hoursPerDay) * (allocation.resource?.lcrCents ?? 0) / 100).toFixed(2);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={popoverStyle}
|
||||
className="bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-800">{role}</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Resource */}
|
||||
<div className="text-xs text-gray-500">
|
||||
Resource: <span className="font-medium text-gray-700">{allocation.resource?.displayName}</span>
|
||||
{" "}· <span className="text-gray-400">{allocation.resource?.eid}</span>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Role</label>
|
||||
<input
|
||||
type="text"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours per day */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Hours / day</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={hoursPerDay ?? ""}
|
||||
onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date range */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Start</label>
|
||||
<DateInput
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">End</label>
|
||||
<DateInput
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Include Saturday */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSaturday}
|
||||
onChange={(e) => setIncludeSaturday(e.target.checked)}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-400"
|
||||
/>
|
||||
<span className="text-xs text-gray-700">Include Saturdays</span>
|
||||
</label>
|
||||
|
||||
{/* Error */}
|
||||
{updateMutation.isError && (
|
||||
<p className="text-xs text-red-600">{updateMutation.error.message}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className={clsx(
|
||||
"flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors",
|
||||
"bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{updateMutation.isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-1.5 rounded-lg text-sm font-medium border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Link to full panel */}
|
||||
<button
|
||||
onClick={() => { onClose(); onOpenPanel(projectId); }}
|
||||
className="w-full text-xs text-brand-600 hover:text-brand-800 text-center pt-1"
|
||||
>
|
||||
Open Project Panel →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { memo } from "react";
|
||||
|
||||
interface ConflictOverlayProps {
|
||||
/** Pixel left offset within the canvas */
|
||||
left: number;
|
||||
/** Pixel width */
|
||||
width: number;
|
||||
/** Row height */
|
||||
height: number;
|
||||
/** Conflict type */
|
||||
type: "availability" | "overlap" | "budget";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const ConflictOverlay = memo(function ConflictOverlay({ left, width, height, type, message }: ConflictOverlayProps) {
|
||||
const colors = {
|
||||
availability: "bg-red-400/30 border-red-400",
|
||||
overlap: "bg-orange-400/30 border-orange-400",
|
||||
budget: "bg-yellow-400/30 border-yellow-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute border-2 rounded-md pointer-events-none",
|
||||
"flex items-center justify-center",
|
||||
colors[type],
|
||||
)}
|
||||
style={{ left, width, height, top: 4 }}
|
||||
title={message}
|
||||
>
|
||||
<span className="text-xs font-medium text-red-700 bg-white/80 px-1 rounded">
|
||||
{type === "availability" ? "⚡ conflict" : type === "overlap" ? "⚠ overlap" : "💰 over budget"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
|
||||
interface NewAllocationPopoverProps {
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
/** Pre-selected project (from project-view sub-row context) */
|
||||
suggestedProjectId?: string | null;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
function toDateInput(d: Date): string {
|
||||
return d.toISOString().split("T")[0] ?? "";
|
||||
}
|
||||
|
||||
export function NewAllocationPopover({
|
||||
resourceId,
|
||||
startDate,
|
||||
endDate,
|
||||
suggestedProjectId,
|
||||
anchorX,
|
||||
anchorY,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: NewAllocationPopoverProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||
suggestedProjectId ?? null,
|
||||
);
|
||||
const [role, setRole] = useState("Team Member");
|
||||
const [hoursPerDay, setHoursPerDay] = useState(8);
|
||||
const [start, setStart] = useState(toDateInput(startDate));
|
||||
const [end, setEnd] = useState(toDateInput(endDate));
|
||||
const [dropdownOpen, setDropdownOpen] = useState(!suggestedProjectId);
|
||||
|
||||
const { data: projectsData } = trpc.project.list.useQuery(
|
||||
{ search, limit: 20 },
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const projects = projectsData?.projects ?? [];
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId)
|
||||
?? (suggestedProjectId ? projects.find((p) => p.id === suggestedProjectId) : null);
|
||||
|
||||
const createMutation = trpc.timeline.quickAssign.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
onCreated();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
function handleCreate() {
|
||||
if (!selectedProjectId) return;
|
||||
createMutation.mutate({
|
||||
resourceId,
|
||||
projectId: selectedProjectId,
|
||||
startDate: new Date(start),
|
||||
endDate: new Date(end),
|
||||
hoursPerDay,
|
||||
role,
|
||||
status: AllocationStatus.PROPOSED,
|
||||
});
|
||||
}
|
||||
|
||||
const canCreate = !!selectedProjectId && !!start && !!end && hoursPerDay > 0;
|
||||
|
||||
const left = Math.min(anchorX - 10, typeof window !== "undefined" ? window.innerWidth - 340 : anchorX);
|
||||
const top = Math.min(anchorY + 8, typeof window !== "undefined" ? window.innerHeight - 440 : anchorY);
|
||||
|
||||
const ORDER_COLORS: Record<string, string> = {
|
||||
CHARGEABLE: "bg-emerald-100 text-emerald-700",
|
||||
INTERNAL: "bg-blue-100 text-blue-700",
|
||||
BD: "bg-violet-100 text-violet-700",
|
||||
OVERHEAD: "bg-gray-100 text-gray-600",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ position: "fixed", left, top, zIndex: 60, width: 320 }}
|
||||
className="bg-white border border-gray-200 rounded-xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-sm font-semibold text-gray-700">Assign to Project</span>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Date range */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Start</label>
|
||||
<DateInput
|
||||
value={start}
|
||||
onChange={setStart}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">End</label>
|
||||
<DateInput
|
||||
value={end}
|
||||
onChange={setEnd}
|
||||
min={start}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project picker */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Project</label>
|
||||
{selectedProject && !dropdownOpen ? (
|
||||
<div
|
||||
className="flex items-center gap-2 border border-brand-300 rounded-lg px-3 py-2 cursor-pointer bg-brand-50"
|
||||
onClick={() => { setDropdownOpen(true); setSearch(""); }}
|
||||
>
|
||||
<span className="text-sm text-gray-800 truncate flex-1">{selectedProject.name}</span>
|
||||
<span className="text-xs text-gray-400">▾</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
autoFocus={dropdownOpen}
|
||||
type="text"
|
||||
placeholder="Search projects…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onFocus={() => setDropdownOpen(true)}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
{dropdownOpen && projects.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 bg-white border border-gray-200 rounded-xl shadow-lg mt-1 max-h-44 overflow-y-auto">
|
||||
{projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => { setSelectedProjectId(p.id); setDropdownOpen(false); setSearch(""); }}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2 border-b border-gray-50 last:border-0"
|
||||
>
|
||||
<span className="text-sm text-gray-800 truncate">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Role</label>
|
||||
<input
|
||||
type="text"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours per day */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Hours / day</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={hoursPerDay}
|
||||
onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
|
||||
className="w-24 border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
{[4, 6, 8].map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
onClick={() => setHoursPerDay(h)}
|
||||
className={clsx(
|
||||
"px-2 py-1 rounded text-xs font-medium border transition-colors",
|
||||
hoursPerDay === h
|
||||
? "bg-brand-600 text-white border-brand-600"
|
||||
: "border-gray-200 text-gray-600 hover:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overbooking notice */}
|
||||
<p className="text-xs text-amber-600 bg-amber-50 px-3 py-2 rounded-lg">
|
||||
Overlapping allocations are allowed — resource may be overbooked.
|
||||
</p>
|
||||
|
||||
{/* Error */}
|
||||
{createMutation.isError && (
|
||||
<p className="text-xs text-red-600">{createMutation.error.message}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!canCreate || createMutation.isPending}
|
||||
className={clsx(
|
||||
"flex-1 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
"bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{createMutation.isPending ? "Creating…" : "Assign"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 rounded-lg text-sm font-medium border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useState } from "react";
|
||||
import { AllocationStatus, type StaffingRequirement } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
|
||||
interface ProjectPanelProps {
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface DemandSummary {
|
||||
id: string;
|
||||
role: string;
|
||||
hoursPerDay: number;
|
||||
requestedHeadcount: number;
|
||||
}
|
||||
|
||||
interface ProjectPanelAssignment {
|
||||
id: string;
|
||||
entityId?: string;
|
||||
sourceAllocationId?: string;
|
||||
resourceId: string;
|
||||
role: string | null;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
metadata: { includeSaturday?: boolean } | null;
|
||||
resource?: {
|
||||
displayName: string;
|
||||
eid: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ProjectPanelDemand {
|
||||
id: string;
|
||||
entityId?: string;
|
||||
sourceAllocationId?: string;
|
||||
role: string | null;
|
||||
hoursPerDay: number;
|
||||
requestedHeadcount: number;
|
||||
roleEntity?: {
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ProjectPanelProject {
|
||||
name: string;
|
||||
orderType?: string;
|
||||
status?: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
budgetCents: number;
|
||||
staffingReqs?: unknown;
|
||||
}
|
||||
|
||||
interface ProjectPanelContext {
|
||||
project: ProjectPanelProject;
|
||||
assignments?: ProjectPanelAssignment[];
|
||||
demands?: ProjectPanelDemand[];
|
||||
}
|
||||
|
||||
interface ProjectPanelResource {
|
||||
id: string;
|
||||
displayName: string;
|
||||
eid: string;
|
||||
chapter?: string | null;
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
green: "bg-green-500",
|
||||
amber: "bg-amber-400",
|
||||
red: "bg-red-500",
|
||||
};
|
||||
|
||||
function toDateInput(d: Date | string): string {
|
||||
return new Date(d).toISOString().split("T")[0] ?? "";
|
||||
}
|
||||
|
||||
function normalizeRole(value: string | null | undefined): string {
|
||||
return (value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: ctx, isLoading } = trpc.timeline.getProjectContext.useQuery(
|
||||
{ projectId },
|
||||
{ staleTime: 5_000 },
|
||||
);
|
||||
|
||||
const { data: budgetStatus } = trpc.timeline.getBudgetStatus.useQuery(
|
||||
{ projectId },
|
||||
{ staleTime: 5_000 },
|
||||
);
|
||||
|
||||
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.allocation.deleteAssignment.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const createAssignmentMutation = trpc.allocation.createAssignment.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
setAddingMember(false);
|
||||
setResourceSearch("");
|
||||
},
|
||||
});
|
||||
|
||||
const [addingMember, setAddingMember] = useState(false);
|
||||
const [resourceSearch, setResourceSearch] = useState("");
|
||||
const [pendingEdits, setPendingEdits] = useState<
|
||||
Record<string, { hoursPerDay?: number; startDate?: string; endDate?: string; includeSaturday?: boolean; role?: string }>
|
||||
>({});
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
const { data: allResources } = trpc.resource.list.useQuery(
|
||||
{ search: resourceSearch },
|
||||
{ enabled: addingMember, staleTime: 10_000 },
|
||||
);
|
||||
|
||||
if (isLoading || !ctx) {
|
||||
return (
|
||||
<PanelShell onClose={onClose}>
|
||||
<div className="flex items-center justify-center h-64 text-gray-400 text-sm">Loading…</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
const { project, assignments = [], demands = [] } = ctx as unknown as ProjectPanelContext;
|
||||
const staffingReqs = (project.staffingReqs as unknown as StaffingRequirement[]) ?? [];
|
||||
const effectiveAssignments = assignments as unknown as ProjectPanelAssignment[];
|
||||
const projectDemands = demands as unknown as ProjectPanelDemand[];
|
||||
const effectiveDemands: DemandSummary[] = projectDemands.length > 0
|
||||
? projectDemands.map((demand) => ({
|
||||
id: demand.id,
|
||||
role: demand.roleEntity?.name ?? demand.role ?? "Unassigned",
|
||||
hoursPerDay: demand.hoursPerDay,
|
||||
requestedHeadcount: demand.requestedHeadcount,
|
||||
}))
|
||||
: staffingReqs.map((req, index) => ({
|
||||
id: `staffing-${index}`,
|
||||
role: req.role,
|
||||
hoursPerDay: req.hoursPerDay,
|
||||
requestedHeadcount: req.headcount,
|
||||
}));
|
||||
|
||||
// Demand vs supply matching
|
||||
const reqMatches = effectiveDemands.map((demand) => {
|
||||
const demandRole = normalizeRole(demand.role);
|
||||
const matched = effectiveAssignments.filter((assignment) =>
|
||||
normalizeRole(assignment.role).includes(demandRole),
|
||||
);
|
||||
const totalHeadcount = matched.length;
|
||||
const fulfilled = totalHeadcount >= demand.requestedHeadcount;
|
||||
const partial = !fulfilled && totalHeadcount > 0;
|
||||
return { demand, matched, fulfilled, partial };
|
||||
});
|
||||
|
||||
const unmatchedAssignments = effectiveAssignments.filter(
|
||||
(assignment) =>
|
||||
!effectiveDemands.some((demand) =>
|
||||
normalizeRole(assignment.role).includes(normalizeRole(demand.role)),
|
||||
),
|
||||
);
|
||||
|
||||
// Budget bar
|
||||
const budgetEUR = (project.budgetCents / 100).toFixed(0);
|
||||
const allocatedEUR = budgetStatus ? (budgetStatus.allocatedCents / 100).toFixed(0) : "—";
|
||||
const utilPct = budgetStatus?.utilizationPercent ?? 0;
|
||||
const budgetBarColor =
|
||||
utilPct >= 100 ? STATUS_COLORS.red : utilPct >= 85 ? STATUS_COLORS.amber : STATUS_COLORS.green;
|
||||
|
||||
function getEdit(id: string) {
|
||||
return pendingEdits[id] ?? {};
|
||||
}
|
||||
|
||||
function setEdit(id: string, patch: typeof pendingEdits[string]) {
|
||||
setPendingEdits((prev) => ({ ...prev, [id]: { ...(prev[id] ?? {}), ...patch } }));
|
||||
}
|
||||
|
||||
function saveEdit(allocId: string) {
|
||||
const edit = getEdit(allocId);
|
||||
const alloc = effectiveAssignments.find((a) => a.id === allocId);
|
||||
if (!alloc) return;
|
||||
updateMutation.mutate({
|
||||
allocationId: getPlanningEntryMutationId(alloc),
|
||||
hoursPerDay: edit.hoursPerDay,
|
||||
startDate: edit.startDate ? new Date(edit.startDate) : undefined,
|
||||
endDate: edit.endDate ? new Date(edit.endDate) : undefined,
|
||||
includeSaturday: edit.includeSaturday,
|
||||
role: edit.role,
|
||||
});
|
||||
setPendingEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[allocId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddMember(resourceId: string) {
|
||||
createAssignmentMutation.mutate({
|
||||
resourceId,
|
||||
projectId,
|
||||
startDate: new Date(project.startDate),
|
||||
endDate: new Date(project.endDate),
|
||||
hoursPerDay: 8,
|
||||
percentage: 100,
|
||||
role: "Team Member",
|
||||
status: AllocationStatus.PROPOSED,
|
||||
metadata: {},
|
||||
});
|
||||
}
|
||||
|
||||
const availableResources = (allResources?.resources ?? []) as unknown as ProjectPanelResource[];
|
||||
const filteredResources = availableResources.filter(
|
||||
(r) => !effectiveAssignments.some((a) => a.resourceId === r.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<PanelShell onClose={onClose}>
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-gray-100">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900">{project.name}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={clsx(
|
||||
"px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
project.orderType === "CHARGEABLE" ? "bg-emerald-100 text-emerald-700" :
|
||||
project.orderType === "BD" ? "bg-violet-100 text-violet-700" :
|
||||
project.orderType === "INTERNAL" ? "bg-blue-100 text-blue-700" :
|
||||
"bg-gray-100 text-gray-600",
|
||||
)}>
|
||||
{project.orderType}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{project.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{toDateInput(project.startDate)} → {toDateInput(project.endDate)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 px-5 py-4 space-y-6">
|
||||
|
||||
{/* Budget section */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Budget</h3>
|
||||
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Allocated</span>
|
||||
<span className="font-semibold text-gray-900">€{allocatedEUR} / €{budgetEUR}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={clsx("h-full rounded-full transition-all", budgetBarColor)}
|
||||
style={{ width: `${Math.min(utilPct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>{utilPct.toFixed(1)}% utilized</span>
|
||||
{budgetStatus && (
|
||||
<span>€{(budgetStatus.remainingCents / 100).toFixed(0)} remaining</span>
|
||||
)}
|
||||
</div>
|
||||
{budgetStatus?.warnings.map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
"text-xs px-2 py-1 rounded-lg",
|
||||
w.level === "critical" ? "bg-red-50 text-red-700" :
|
||||
w.level === "warning" ? "bg-amber-50 text-amber-700" :
|
||||
"bg-blue-50 text-blue-700",
|
||||
)}
|
||||
>
|
||||
{w.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Demand vs Supply */}
|
||||
{effectiveDemands.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||
Demand vs Supply
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{reqMatches.map(({ demand, matched, fulfilled, partial }) => (
|
||||
<div key={demand.id} className="border border-gray-100 rounded-xl overflow-hidden">
|
||||
<div className={clsx(
|
||||
"flex items-center justify-between px-3 py-2",
|
||||
fulfilled ? "bg-green-50" : partial ? "bg-amber-50" : "bg-red-50",
|
||||
)}>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-800">{demand.role}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">{demand.requestedHeadcount} needed · {demand.hoursPerDay}h/day</span>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
"text-xs font-semibold",
|
||||
fulfilled ? "text-green-600" : partial ? "text-amber-600" : "text-red-600",
|
||||
)}>
|
||||
{fulfilled ? "✓ Filled" : partial ? `${matched.length}/${demand.requestedHeadcount}` : "Unfilled"}
|
||||
</span>
|
||||
</div>
|
||||
{matched.length > 0 && (
|
||||
<div className="px-3 py-1.5 bg-white border-t border-gray-100 space-y-0.5">
|
||||
{matched.map((a) => (
|
||||
<div key={a.id} className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span>{a.resource?.displayName}</span>
|
||||
<span className="text-gray-400">{a.hoursPerDay}h/day</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{unmatchedAssignments.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-gray-400 mb-1">Unmatched assignments</div>
|
||||
{unmatchedAssignments.map((a) => (
|
||||
<div key={a.id} className="text-xs text-gray-500 flex justify-between">
|
||||
<span>{a.resource?.displayName} — {a.role}</span>
|
||||
<span>{a.hoursPerDay}h/day</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Team */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Team</h3>
|
||||
<button
|
||||
onClick={() => setAddingMember(true)}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
+ Add Member
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resource search for adding */}
|
||||
{addingMember && (
|
||||
<div className="mb-3 border border-gray-200 rounded-xl p-3 bg-gray-50 space-y-2">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Search by name or EID…"
|
||||
value={resourceSearch}
|
||||
onChange={(e) => setResourceSearch(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
{filteredResources.slice(0, 8).map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => handleAddMember(r.id)}
|
||||
disabled={createAssignmentMutation.isPending}
|
||||
className="w-full text-left px-3 py-2 rounded-lg hover:bg-white text-sm text-gray-800 border border-transparent hover:border-gray-200 transition-colors"
|
||||
>
|
||||
<span className="font-medium">{r.displayName}</span>
|
||||
<span className="text-gray-400 ml-2 text-xs">{r.eid}</span>
|
||||
{r.chapter && <span className="text-gray-300 ml-1 text-xs">· {r.chapter}</span>}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => { setAddingMember(false); setResourceSearch(""); }}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{effectiveAssignments.map((alloc) => {
|
||||
const edit = getEdit(alloc.id);
|
||||
const isDirty = Object.keys(edit).length > 0;
|
||||
const meta = alloc.metadata as { includeSaturday?: boolean } | null;
|
||||
const inclSat = edit.includeSaturday ?? meta?.includeSaturday ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alloc.id}
|
||||
className="border border-gray-100 rounded-xl p-3 space-y-2 bg-white"
|
||||
>
|
||||
{/* Resource name + delete */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-800">{alloc.resource?.displayName}</span>
|
||||
<span className="text-xs text-gray-400 ml-1.5">{alloc.resource?.eid}</span>
|
||||
</div>
|
||||
{confirmDelete === alloc.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-red-600">Remove?</span>
|
||||
<button
|
||||
onClick={() => { deleteMutation.mutate({ id: getPlanningEntryMutationId(alloc) }); setConfirmDelete(null); }}
|
||||
className="text-xs text-red-600 font-medium hover:text-red-800"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(alloc.id)}
|
||||
className="text-xs text-red-400 hover:text-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<input
|
||||
type="text"
|
||||
value={edit.role ?? alloc.role ?? ""}
|
||||
onChange={(e) => setEdit(alloc.id, { role: e.target.value })}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
placeholder="Role"
|
||||
/>
|
||||
|
||||
{/* Dates + hours */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-400 mb-0.5">Start</label>
|
||||
<DateInput
|
||||
value={edit.startDate ?? toDateInput(alloc.startDate)}
|
||||
onChange={(v) => setEdit(alloc.id, { startDate: v })}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-400 mb-0.5">End</label>
|
||||
<DateInput
|
||||
value={edit.endDate ?? toDateInput(alloc.endDate)}
|
||||
onChange={(v) => setEdit(alloc.id, { endDate: v })}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-400 mb-0.5">h/day</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={edit.hoursPerDay ?? alloc.hoursPerDay}
|
||||
onChange={(e) => setEdit(alloc.id, { hoursPerDay: parseFloat(e.target.value) })}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saturday toggle */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inclSat}
|
||||
onChange={(e) => setEdit(alloc.id, { includeSaturday: e.target.checked })}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-400"
|
||||
/>
|
||||
<span className="text-xs text-gray-600">Include Saturdays</span>
|
||||
</label>
|
||||
|
||||
{/* Save button */}
|
||||
{isDirty && (
|
||||
<button
|
||||
onClick={() => saveEdit(alloc.id)}
|
||||
disabled={updateMutation.isPending}
|
||||
className="w-full py-1.5 rounded-lg text-xs font-medium bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{effectiveAssignments.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-gray-400">
|
||||
No team members yet. Add one above.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelShell({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-y-0 right-0 w-[420px] bg-white border-l border-gray-200 shadow-2xl z-40 flex flex-col">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
|
||||
<span className="text-sm font-semibold text-gray-700">Project Details</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-h-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { memo } from "react";
|
||||
import type { ShiftPreviewData } from "~/hooks/useTimelineDrag.js";
|
||||
import { formatDate } from "~/lib/format.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
|
||||
interface ShiftPreviewTooltipProps {
|
||||
preview: ShiftPreviewData;
|
||||
projectName: string;
|
||||
newStartDate: Date;
|
||||
newEndDate: Date;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
const abs = Math.abs(cents);
|
||||
const str = (abs / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 });
|
||||
return `${cents < 0 ? "−" : "+"}${str} €`;
|
||||
}
|
||||
|
||||
export const ShiftPreviewTooltip = memo(function ShiftPreviewTooltip({
|
||||
preview,
|
||||
projectName,
|
||||
newStartDate,
|
||||
newEndDate,
|
||||
isLoading,
|
||||
}: ShiftPreviewTooltipProps) {
|
||||
const { canViewCosts } = usePermissions();
|
||||
const dateStr = `${formatDate(newStartDate)} → ${formatDate(newEndDate)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-white border rounded-xl shadow-2xl p-3 min-w-56 max-w-72 text-sm",
|
||||
preview.valid ? "border-gray-200" : "border-red-300",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="font-semibold text-gray-900 truncate mb-2">{projectName}</div>
|
||||
<div className="text-xs text-gray-500 mb-3 font-mono">{dateStr}</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-xs text-gray-400 animate-pulse">Calculating...</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Cost delta */}
|
||||
{canViewCosts && preview.deltaCents !== 0 && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-gray-500">Cost delta</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"text-xs font-mono font-medium",
|
||||
preview.deltaCents > 0 ? "text-red-600" : "text-green-600",
|
||||
)}
|
||||
>
|
||||
{formatCents(preview.deltaCents)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Budget utilization */}
|
||||
{canViewCosts && (
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-gray-500">Budget after</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"text-xs font-medium",
|
||||
preview.wouldExceedBudget
|
||||
? "text-red-600"
|
||||
: preview.budgetUtilizationAfter > 85
|
||||
? "text-yellow-600"
|
||||
: "text-gray-700",
|
||||
)}
|
||||
>
|
||||
{preview.budgetUtilizationAfter.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conflicts */}
|
||||
{preview.conflictCount > 0 && (
|
||||
<div className="mb-2 text-xs text-yellow-700 bg-yellow-50 rounded-lg px-2 py-1.5">
|
||||
⚠ {preview.conflictCount} availability conflict{preview.conflictCount > 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Errors */}
|
||||
{preview.errors.map((err, i) => (
|
||||
<div key={i} className="mb-1 text-xs text-red-700 bg-red-50 rounded-lg px-2 py-1.5">
|
||||
✗ {err}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Warnings */}
|
||||
{preview.warnings.slice(0, 2).map((warn, i) => (
|
||||
<div key={i} className="mb-1 text-xs text-yellow-700 bg-yellow-50 rounded-lg px-2 py-1">
|
||||
{warn}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Action hint */}
|
||||
<div className="mt-2 text-xs text-gray-400 text-center">
|
||||
{preview.valid ? "Release to apply shift" : "Cannot apply — resolve errors first"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useRef, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
export interface TimelineFilters {
|
||||
chapters: string[];
|
||||
/** Filter to specific resource EIDs */
|
||||
eids: string[];
|
||||
/** Filter to specific project IDs */
|
||||
projectIds: string[];
|
||||
showWeekends: boolean;
|
||||
zoom: "day" | "week" | "month";
|
||||
/**
|
||||
* Hide allocations whose project has status COMPLETED or CANCELLED.
|
||||
* Defaults to the user's global app preference; can be toggled per session.
|
||||
*/
|
||||
hideCompletedProjects: boolean;
|
||||
/** Show DRAFT projects and their PROPOSED allocations on the timeline. */
|
||||
showDrafts: boolean;
|
||||
/** Show approved vacation blocks on resource rows. Default: true. */
|
||||
showVacations: boolean;
|
||||
/** Show open-demand entries (no resource assigned yet). Default: true. */
|
||||
showPlaceholders: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FILTERS: TimelineFilters = {
|
||||
chapters: [],
|
||||
eids: [],
|
||||
projectIds: [],
|
||||
showWeekends: false,
|
||||
zoom: "day",
|
||||
hideCompletedProjects: true, // overridden at runtime from AppPreferences
|
||||
showDrafts: false,
|
||||
showVacations: true,
|
||||
showPlaceholders: true,
|
||||
};
|
||||
|
||||
interface TimelineFilterProps {
|
||||
filters: TimelineFilters;
|
||||
onChange: (filters: TimelineFilters) => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ─── Chip ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Chip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-brand-50 border border-brand-200 text-brand-700 rounded-full text-xs font-medium">
|
||||
{label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="text-brand-400 hover:text-brand-700 leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── EID picker ───────────────────────────────────────────────────────────────
|
||||
|
||||
function EidPicker({
|
||||
selectedEids,
|
||||
onChange,
|
||||
}: {
|
||||
selectedEids: string[];
|
||||
onChange: (eids: string[]) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data } = trpc.resource.list.useQuery(
|
||||
{ search, isActive: true, limit: 200 },
|
||||
{ staleTime: 15_000 },
|
||||
);
|
||||
type ResourceRow = { id: string; eid: string; displayName: string; chapter: string | null };
|
||||
const suggestions = (data?.resources as ResourceRow[] | undefined ?? []).filter((r) => !selectedEids.includes(r.eid));
|
||||
|
||||
function add(eid: string) {
|
||||
onChange([...selectedEids, eid]);
|
||||
setSearch("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function remove(eid: string) {
|
||||
onChange(selectedEids.filter((e) => e !== eid));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||
{selectedEids.map((eid) => (
|
||||
<Chip key={eid} label={eid} onRemove={() => remove(eid)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search by name or EID…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
{open && suggestions.length > 0 && (
|
||||
<div
|
||||
className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg max-h-40 overflow-y-auto"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{suggestions.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); add(r.eid); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 flex items-center gap-2"
|
||||
>
|
||||
<span className="font-mono text-gray-500 w-16 flex-shrink-0">{r.eid}</span>
|
||||
<span className="text-gray-800 truncate">{r.displayName}</span>
|
||||
{r.chapter && <span className="text-gray-400 flex-shrink-0">{r.chapter}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Project picker ───────────────────────────────────────────────────────────
|
||||
|
||||
function ProjectPicker({
|
||||
selectedIds,
|
||||
onChange,
|
||||
}: {
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data } = trpc.project.list.useQuery(
|
||||
{ search, limit: 200 },
|
||||
{ staleTime: 15_000 },
|
||||
);
|
||||
type ProjectRow = { id: string; shortCode: string; name: string };
|
||||
const suggestions = (data?.projects as ProjectRow[] | undefined ?? []).filter((p) => !selectedIds.includes(p.id));
|
||||
|
||||
// Labels for selected chips — need to resolve names
|
||||
const { data: allData } = trpc.project.list.useQuery(
|
||||
{ limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const projectMap = new Map((allData?.projects as ProjectRow[] | undefined ?? []).map((p) => [p.id, p]));
|
||||
|
||||
function add(id: string) {
|
||||
onChange([...selectedIds, id]);
|
||||
setSearch("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
onChange(selectedIds.filter((i) => i !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||
{selectedIds.map((id) => {
|
||||
const p = projectMap.get(id);
|
||||
return (
|
||||
<Chip
|
||||
key={id}
|
||||
label={p ? p.name : id}
|
||||
onRemove={() => remove(id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search projects…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
{open && suggestions.length > 0 && (
|
||||
<div
|
||||
className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg max-h-40 overflow-y-auto"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{suggestions.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); add(p.id); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 flex items-center gap-2"
|
||||
>
|
||||
<span className="text-gray-800 truncate">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main filter panel ────────────────────────────────────────────────────────
|
||||
|
||||
export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineFilterProps) {
|
||||
const { data: resourceData } = trpc.resource.list.useQuery({ isActive: true, limit: 500 });
|
||||
const chapters = [
|
||||
...new Set(
|
||||
(resourceData?.resources as Array<{ chapter: string | null }> | undefined ?? []).map((r) => r.chapter).filter(Boolean) as string[],
|
||||
),
|
||||
].sort();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const activeCount =
|
||||
filters.chapters.length + filters.eids.length + filters.projectIds.length;
|
||||
|
||||
return (
|
||||
<div className="absolute right-0 top-12 z-30 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl w-80 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
Filters
|
||||
{activeCount > 0 && (
|
||||
<span className="ml-2 text-xs font-normal text-brand-600">{activeCount} active</span>
|
||||
)}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Zoom level */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Zoom</label>
|
||||
<div className="flex gap-2">
|
||||
{(["day", "week", "month"] as const).map((z) => (
|
||||
<button
|
||||
key={z}
|
||||
onClick={() => onChange({ ...filters, zoom: z })}
|
||||
className={clsx(
|
||||
"flex-1 px-2 py-1.5 text-xs rounded-lg border capitalize",
|
||||
filters.zoom === z
|
||||
? "bg-brand-50 border-brand-300 text-brand-700"
|
||||
: "border-gray-200 text-gray-600 hover:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
{z}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EID filter */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
People (EID)
|
||||
</label>
|
||||
<EidPicker
|
||||
selectedEids={filters.eids}
|
||||
onChange={(eids) => onChange({ ...filters, eids })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project filter */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
Projects
|
||||
</label>
|
||||
<ProjectPicker
|
||||
selectedIds={filters.projectIds}
|
||||
onChange={(projectIds) => onChange({ ...filters, projectIds })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chapters */}
|
||||
{chapters.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
Chapters
|
||||
</label>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{chapters.map((ch) => (
|
||||
<label key={ch} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.chapters.includes(ch)}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked
|
||||
? [...filters.chapters, ch]
|
||||
: filters.chapters.filter((c) => c !== ch);
|
||||
onChange({ ...filters, chapters: next });
|
||||
}}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">{ch}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visibility toggles */}
|
||||
<div className="mb-4 space-y-2">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Visibility</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.showWeekends}
|
||||
onChange={(e) => onChange({ ...filters, showWeekends: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">Show weekends</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!filters.hideCompletedProjects}
|
||||
onChange={(e) => onChange({ ...filters, hideCompletedProjects: !e.target.checked })}
|
||||
className="rounded border-gray-300 mt-0.5"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
Show completed & cancelled
|
||||
<span className="block text-xs text-gray-400 font-normal">Default set in Preferences</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.showDrafts}
|
||||
onChange={(e) => onChange({ ...filters, showDrafts: e.target.checked })}
|
||||
className="rounded border-gray-300 mt-0.5"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
Show draft projects
|
||||
<span className="block text-xs text-gray-400 font-normal">Shows PROPOSED allocations</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.showVacations}
|
||||
onChange={(e) => onChange({ ...filters, showVacations: e.target.checked })}
|
||||
className="rounded border-gray-300 mt-0.5"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
Show vacation blocks
|
||||
<span className="block text-xs text-gray-400 font-normal">Approved leave on resource rows</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.showPlaceholders}
|
||||
onChange={(e) => onChange({ ...filters, showPlaceholders: e.target.checked })}
|
||||
className="rounded border-gray-300 mt-0.5"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
Show open demand
|
||||
<span className="block text-xs text-gray-400 font-normal">Dashed bars for unassigned staffing demand</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onChange(DEFAULT_FILTERS)}
|
||||
disabled={activeCount === 0 && !filters.showWeekends && filters.hideCompletedProjects && !filters.showDrafts && filters.showVacations && filters.showPlaceholders}
|
||||
className="w-full text-xs text-gray-500 hover:text-gray-700 underline disabled:opacity-40 disabled:no-underline"
|
||||
>
|
||||
Reset all filters
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { MONTHS_SHORT } from "./timelineConstants.js";
|
||||
|
||||
interface TimelineHeaderProps {
|
||||
monthGroups: { label: string; colCount: number }[];
|
||||
dates: Date[];
|
||||
CELL_WIDTH: number;
|
||||
LABEL_WIDTH: number;
|
||||
HEADER_MONTH_HEIGHT: number;
|
||||
HEADER_DAY_HEIGHT: number;
|
||||
zoom: "day" | "week" | "month";
|
||||
viewMode: "resource" | "project";
|
||||
today: Date;
|
||||
}
|
||||
|
||||
export function TimelineHeader({
|
||||
monthGroups,
|
||||
dates,
|
||||
CELL_WIDTH,
|
||||
LABEL_WIDTH,
|
||||
HEADER_MONTH_HEIGHT,
|
||||
HEADER_DAY_HEIGHT,
|
||||
zoom,
|
||||
viewMode,
|
||||
today,
|
||||
}: TimelineHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Month header */}
|
||||
<div
|
||||
className="sticky top-0 z-40 flex bg-white border-b border-gray-100"
|
||||
style={{ height: HEADER_MONTH_HEIGHT }}
|
||||
>
|
||||
<div className="flex-shrink-0 border-r border-gray-200" style={{ width: LABEL_WIDTH }} />
|
||||
<div className="flex">
|
||||
{monthGroups.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs font-semibold text-gray-500 border-r border-gray-200 px-2 flex items-center bg-gray-50"
|
||||
style={{ width: m.colCount * CELL_WIDTH }}
|
||||
>
|
||||
{m.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day header — hidden at month zoom (cells too narrow for labels) */}
|
||||
{zoom !== "month" && (
|
||||
<div
|
||||
className="sticky z-40 flex bg-gray-50 border-b border-gray-200 select-none"
|
||||
style={{ top: HEADER_MONTH_HEIGHT, height: HEADER_DAY_HEIGHT }}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 border-r border-gray-200 flex items-center px-4 text-xs font-medium text-gray-400 uppercase tracking-wider"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
{viewMode === "resource" ? "Resource" : "Project / Resource"}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{dates.map((date, i) => {
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
const isMonday = date.getDay() === 1;
|
||||
const isSaturday = date.getDay() === 6;
|
||||
const isSunday = date.getDay() === 0;
|
||||
// Week zoom: show label only on Mondays to avoid overcrowding
|
||||
const showLabel = zoom === "day" || isMonday;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
"flex-shrink-0 border-r flex flex-col items-center justify-center text-xs overflow-hidden",
|
||||
isToday ? "bg-brand-50 border-brand-200" :
|
||||
isSaturday ? "bg-amber-50/60 border-amber-200" :
|
||||
isSunday ? "bg-gray-100/80 border-gray-200" :
|
||||
isMonday ? "border-gray-200" : "border-gray-100",
|
||||
)}
|
||||
style={{ width: CELL_WIDTH, height: HEADER_DAY_HEIGHT }}
|
||||
>
|
||||
{showLabel && (
|
||||
<>
|
||||
<span className={clsx(
|
||||
"font-medium leading-none",
|
||||
isToday ? "text-brand-600" : isSaturday ? "text-amber-600" : "text-gray-600",
|
||||
)}>
|
||||
{zoom === "week"
|
||||
? `${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}`
|
||||
: date.getDate()}
|
||||
</span>
|
||||
{zoom === "day" && (
|
||||
<span className={clsx(
|
||||
"text-[9px] leading-none mt-0.5",
|
||||
isSaturday ? "text-amber-400" : "text-gray-300",
|
||||
)}>
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][date.getDay()]}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { TimelineFilter, type TimelineFilters } from "./TimelineFilter.js";
|
||||
|
||||
interface TimelineToolbarProps {
|
||||
viewMode: "resource" | "project";
|
||||
onViewModeChange: (mode: "resource" | "project") => void;
|
||||
filters: TimelineFilters;
|
||||
onFiltersChange: (f: TimelineFilters) => void;
|
||||
filterOpen: boolean;
|
||||
onFilterOpenChange: (open: boolean) => void;
|
||||
resourceCount: number;
|
||||
projectCount: number;
|
||||
totalAllocCount: number;
|
||||
onNavigateBack: () => void;
|
||||
onNavigateToday: () => void;
|
||||
onNavigateForward: () => void;
|
||||
canUndo?: boolean;
|
||||
canRedo?: boolean;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
}
|
||||
|
||||
export function TimelineToolbar({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
filterOpen,
|
||||
onFilterOpenChange,
|
||||
resourceCount,
|
||||
projectCount,
|
||||
totalAllocCount,
|
||||
onNavigateBack,
|
||||
onNavigateToday,
|
||||
onNavigateForward,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}: TimelineToolbarProps) {
|
||||
const activeFilterCount = filters.chapters.length + filters.eids.length + filters.projectIds.length;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-2 gap-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
{viewMode === "resource"
|
||||
? `${resourceCount} resources · ${totalAllocCount} allocations`
|
||||
: `${projectCount} projects`}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Timeline navigation */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
className="px-2.5 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
title="Previous 4 weeks"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
onClick={onNavigateToday}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={onNavigateForward}
|
||||
className="px-2.5 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
title="Next 4 weeks"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Undo / Redo */}
|
||||
{(onUndo ?? onRedo) && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
title="Undo (Ctrl+Z)"
|
||||
className="px-2 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
<button
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
title="Redo (Ctrl+Shift+Z / Ctrl+Y)"
|
||||
className="px-2 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
↪
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View mode toggle */}
|
||||
<div className="flex rounded-lg border border-gray-200 overflow-hidden text-sm">
|
||||
<button
|
||||
onClick={() => onViewModeChange("resource")}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 transition-colors",
|
||||
viewMode === "resource"
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-gray-600 hover:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
Resource view
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewModeChange("project")}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 border-l border-gray-200 transition-colors",
|
||||
viewMode === "project"
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-gray-600 hover:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
Project view
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => onFilterOpenChange(!filterOpen)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg border transition-colors",
|
||||
filterOpen || activeFilterCount > 0
|
||||
? "bg-brand-50 border-brand-300 text-brand-700"
|
||||
: "border-gray-200 text-gray-600 hover:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
Filter
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="w-4 h-4 rounded-full bg-brand-600 text-white text-xs flex items-center justify-center">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<TimelineFilter
|
||||
filters={filters}
|
||||
onChange={onFiltersChange}
|
||||
isOpen={filterOpen}
|
||||
onClose={() => onFilterOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
|
||||
|
||||
// ─── Heatmap colour palettes ───────────────────────────────────────────────────
|
||||
// Each palette is [minPct, overlayRgba, barRgba] — overlay is semi-transparent
|
||||
// (for heatmap mode strips), bar is more opaque (for bar-mode project view).
|
||||
export const HEATMAP_PALETTES: Record<HeatmapColorScheme, [number, string, string][]> = {
|
||||
"green-red": [
|
||||
[0, "rgba(34,197,94,0.18)", "rgba(34,197,94,0.70)"],
|
||||
[25, "rgba(132,204,22,0.25)", "rgba(132,204,22,0.75)"],
|
||||
[50, "rgba(250,204,21,0.32)", "rgba(250,204,21,0.80)"],
|
||||
[75, "rgba(249,115,22,0.40)", "rgba(249,115,22,0.82)"],
|
||||
[90, "rgba(239,68,68,0.48)", "rgba(239,68,68,0.85)"],
|
||||
[100, "rgba(185,28,28,0.58)", "rgba(185,28,28,0.88)"],
|
||||
[125, "rgba(109,40,217,0.64)", "rgba(109,40,217,0.88)"],
|
||||
],
|
||||
"blue-orange": [
|
||||
[0, "rgba(56,189,248,0.22)", "rgba(56,189,248,0.70)"],
|
||||
[25, "rgba(59,130,246,0.28)", "rgba(59,130,246,0.75)"],
|
||||
[50, "rgba(251,191,36,0.35)", "rgba(251,191,36,0.80)"],
|
||||
[75, "rgba(249,115,22,0.42)", "rgba(249,115,22,0.82)"],
|
||||
[90, "rgba(239,68,68,0.50)", "rgba(239,68,68,0.85)"],
|
||||
[100, "rgba(185,28,28,0.58)", "rgba(185,28,28,0.88)"],
|
||||
[125, "rgba(109,40,217,0.64)", "rgba(109,40,217,0.88)"],
|
||||
],
|
||||
"purple-yellow": [
|
||||
[0, "rgba(167,139,250,0.22)", "rgba(167,139,250,0.70)"],
|
||||
[25, "rgba(139,92,246,0.28)", "rgba(139,92,246,0.75)"],
|
||||
[50, "rgba(250,204,21,0.35)", "rgba(250,204,21,0.80)"],
|
||||
[75, "rgba(245,158,11,0.42)", "rgba(245,158,11,0.82)"],
|
||||
[90, "rgba(239,68,68,0.50)", "rgba(239,68,68,0.85)"],
|
||||
[100, "rgba(185,28,28,0.58)", "rgba(185,28,28,0.88)"],
|
||||
[125, "rgba(109,40,217,0.64)", "rgba(109,40,217,0.88)"],
|
||||
],
|
||||
"mono": [
|
||||
[0, "rgba(156,163,175,0.18)", "rgba(156,163,175,0.60)"],
|
||||
[25, "rgba(107,114,128,0.25)", "rgba(107,114,128,0.68)"],
|
||||
[50, "rgba(75,85,99,0.30)", "rgba(75,85,99,0.74)"],
|
||||
[75, "rgba(55,65,81,0.36)", "rgba(55,65,81,0.80)"],
|
||||
[90, "rgba(31,41,55,0.42)", "rgba(31,41,55,0.85)"],
|
||||
[100, "rgba(17,24,39,0.52)", "rgba(17,24,39,0.88)"],
|
||||
[125, "rgba(0,0,0,0.60)", "rgba(0,0,0,0.90)"],
|
||||
],
|
||||
};
|
||||
|
||||
// pct = (totalHoursPerDay / 8) * 100. Returns rgba string or null for 0%.
|
||||
// mode: "overlay" for heatmap strips, "bar" for solid bar fill.
|
||||
export function heatmapColor(pct: number, scheme: HeatmapColorScheme, mode: "overlay" | "bar" = "overlay"): string | null {
|
||||
if (pct <= 0) return null;
|
||||
const palette = HEATMAP_PALETTES[scheme] ?? HEATMAP_PALETTES["green-red"];
|
||||
let entry = palette[0]!;
|
||||
for (const row of palette) {
|
||||
if (pct > row[0]) entry = row;
|
||||
else break;
|
||||
}
|
||||
return mode === "bar" ? entry[2] : entry[1];
|
||||
}
|
||||
|
||||
// Legacy alias used by heatmap overlay (overlay mode, green-red default)
|
||||
export function heatmapBgColor(pct: number, scheme: HeatmapColorScheme = "green-red"): string | null {
|
||||
return heatmapColor(pct, scheme, "overlay");
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// ─── Layout constants ──────────────────────────────────────────────────────────
|
||||
export const ROW_HEIGHT = 52;
|
||||
export const SUB_LANE_HEIGHT = 36;
|
||||
export const HEADER_DAY_HEIGHT = 28;
|
||||
export const HEADER_MONTH_HEIGHT = 24;
|
||||
export const LABEL_WIDTH = 256;
|
||||
export const PROJECT_HEADER_HEIGHT = 40;
|
||||
|
||||
export const MONTHS_SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
|
||||
export const ORDER_TYPE_COLORS: Record<string, { bg: string; text: string; light: string }> = {
|
||||
CHARGEABLE: { bg: "bg-emerald-500", text: "text-white", light: "bg-emerald-50 border-emerald-200 dark:bg-emerald-950 dark:border-emerald-800" },
|
||||
INTERNAL: { bg: "bg-blue-500", text: "text-white", light: "bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-800" },
|
||||
BD: { bg: "bg-violet-500", text: "text-white", light: "bg-violet-50 border-violet-200 dark:bg-violet-950 dark:border-violet-800" },
|
||||
OVERHEAD: { bg: "bg-slate-400", text: "text-white", light: "bg-slate-50 border-slate-200 dark:bg-slate-800 dark:border-slate-700" },
|
||||
};
|
||||
|
||||
export const ORDER_TYPE_BADGE: Record<string, string> = {
|
||||
CHARGEABLE: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300",
|
||||
INTERNAL: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300",
|
||||
BD: "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300",
|
||||
OVERHEAD: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300",
|
||||
};
|
||||
|
||||
export const DONE_STATUSES = new Set(["COMPLETED", "CANCELLED"]);
|
||||
@@ -0,0 +1,220 @@
|
||||
// ─── Date helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function daysBetween(a: Date, b: Date): number {
|
||||
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a date to a left-pixel offset within the visible timeline.
|
||||
* Accounts for weekend-skipping when showWeekends is false.
|
||||
*/
|
||||
export function dateToLeft(
|
||||
date: Date,
|
||||
viewStart: Date,
|
||||
viewEnd: Date,
|
||||
cellWidth: number,
|
||||
showWeekends: boolean,
|
||||
): number {
|
||||
const clamped = date < viewStart ? viewStart : date > viewEnd ? viewEnd : date;
|
||||
if (showWeekends) {
|
||||
return daysBetween(viewStart, clamped) * cellWidth;
|
||||
}
|
||||
let count = 0;
|
||||
const cur = new Date(viewStart);
|
||||
cur.setHours(0, 0, 0, 0);
|
||||
const target = new Date(clamped);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
while (cur < target) {
|
||||
const dow = cur.getDay();
|
||||
if (dow !== 0 && dow !== 6) count++;
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
return count * cellWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the pixel width of a date range within the visible timeline.
|
||||
* Accounts for weekend-skipping when showWeekends is false.
|
||||
*/
|
||||
export function dateRangeToWidth(
|
||||
start: Date,
|
||||
end: Date,
|
||||
viewStart: Date,
|
||||
viewEnd: Date,
|
||||
cellWidth: number,
|
||||
showWeekends: boolean,
|
||||
): number {
|
||||
let count = 0;
|
||||
const cur = new Date(start < viewStart ? viewStart : start);
|
||||
cur.setHours(0, 0, 0, 0);
|
||||
const endC = new Date(end > viewEnd ? viewEnd : end);
|
||||
endC.setHours(0, 0, 0, 0);
|
||||
while (cur <= endC) {
|
||||
const dow = cur.getDay();
|
||||
if (showWeekends || (dow !== 0 && dow !== 6)) count++;
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
return count * cellWidth;
|
||||
}
|
||||
|
||||
// ─── O(1) position cache ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pre-computes a pixel-offset lookup table for the entire visible date range.
|
||||
* Returns `toLeft` / `toWidth` with O(1) lookups instead of O(n) loops.
|
||||
* Use inside a `useMemo` that depends on viewStart / viewDays / cellWidth / showWeekends.
|
||||
*/
|
||||
export function createDatePositionCache(
|
||||
viewStart: Date,
|
||||
viewDays: number,
|
||||
cellWidth: number,
|
||||
showWeekends: boolean,
|
||||
): { toLeft: (date: Date) => number; toWidth: (start: Date, end: Date) => number } {
|
||||
// offsetMap: day-start-timestamp → pixel left rankMap: same → 0-based visible-day index
|
||||
const offsetMap = new Map<number, number>();
|
||||
const rankMap = new Map<number, number>();
|
||||
|
||||
let rank = 0;
|
||||
const cur = new Date(viewStart);
|
||||
cur.setHours(0, 0, 0, 0);
|
||||
const viewStartT = cur.getTime();
|
||||
|
||||
for (let i = 0; i < viewDays; i++) {
|
||||
const dow = cur.getDay();
|
||||
if (showWeekends || (dow !== 0 && dow !== 6)) {
|
||||
const t = cur.getTime();
|
||||
offsetMap.set(t, rank * cellWidth);
|
||||
rankMap.set(t, rank);
|
||||
rank++;
|
||||
}
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
|
||||
const totalWidth = rank * cellWidth;
|
||||
const viewEndT = cur.getTime(); // timestamp of the day AFTER the last visible day
|
||||
|
||||
function toLeft(date: Date): number {
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
const t = d.getTime();
|
||||
if (t <= viewStartT) return 0;
|
||||
if (t >= viewEndT) return totalWidth;
|
||||
const cached = offsetMap.get(t);
|
||||
if (cached !== undefined) return cached;
|
||||
// Weekend when showWeekends=false: return the *next* visible day's offset.
|
||||
// This matches the original dateToLeft which counts strictly-before business days,
|
||||
// so Saturday gets the same offset as the following Monday.
|
||||
const next = new Date(d);
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
if (next.getTime() >= viewEndT) return totalWidth;
|
||||
const v = offsetMap.get(next.getTime());
|
||||
if (v !== undefined) return v;
|
||||
}
|
||||
return totalWidth;
|
||||
}
|
||||
|
||||
function toWidth(start: Date, end: Date): number {
|
||||
const sNorm = new Date(start < viewStart ? viewStart : start);
|
||||
sNorm.setHours(0, 0, 0, 0);
|
||||
const eNorm = new Date(end);
|
||||
eNorm.setHours(0, 0, 0, 0);
|
||||
|
||||
// Rank of the first visible day at-or-after sNorm
|
||||
let sRank: number;
|
||||
const sT = sNorm.getTime();
|
||||
const rS = rankMap.get(sT);
|
||||
if (rS !== undefined) {
|
||||
sRank = rS;
|
||||
} else {
|
||||
sRank = rank; // default: past end → 0 width
|
||||
const next = new Date(sNorm);
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
if (next.getTime() >= viewEndT) break;
|
||||
const r = rankMap.get(next.getTime());
|
||||
if (r !== undefined) { sRank = r; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// Rank of the last visible day at-or-before eNorm
|
||||
let eRank: number;
|
||||
const eT = eNorm.getTime();
|
||||
if (eT >= viewEndT) {
|
||||
eRank = rank - 1; // clamp to last visible day
|
||||
} else {
|
||||
const rE = rankMap.get(eT);
|
||||
if (rE !== undefined) {
|
||||
eRank = rE;
|
||||
} else {
|
||||
eRank = sRank - 1; // default: no visible day → 0 width
|
||||
const prev = new Date(eNorm);
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
prev.setDate(prev.getDate() - 1);
|
||||
if (prev.getTime() < viewStartT) break;
|
||||
const r = rankMap.get(prev.getTime());
|
||||
if (r !== undefined) { eRank = r; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (eRank < sRank) return 0;
|
||||
return (eRank - sRank + 1) * cellWidth;
|
||||
}
|
||||
|
||||
return { toLeft, toWidth };
|
||||
}
|
||||
|
||||
// ─── Sub-lane computation ──────────────────────────────────────────────────────
|
||||
|
||||
export interface SubLaneAlloc {
|
||||
id: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy lane assignment for overlapping allocations.
|
||||
* Returns a map of allocationId → lane index (0-based).
|
||||
* Allocations are sorted by startDate then assigned to the first lane
|
||||
* that doesn't overlap with the previous occupant.
|
||||
*/
|
||||
export function computeSubLanes(allocs: SubLaneAlloc[]): Map<string, number> {
|
||||
const sorted = [...allocs].sort(
|
||||
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(),
|
||||
);
|
||||
|
||||
// laneEnds[i] = end date of the last allocation placed in lane i
|
||||
const laneEnds: Date[] = [];
|
||||
const result = new Map<string, number>();
|
||||
|
||||
for (const alloc of sorted) {
|
||||
const start = new Date(alloc.startDate);
|
||||
const end = new Date(alloc.endDate);
|
||||
|
||||
let placed = false;
|
||||
for (let i = 0; i < laneEnds.length; i++) {
|
||||
const laneEnd = laneEnds[i]!;
|
||||
if (start > laneEnd) {
|
||||
// Lane is free
|
||||
laneEnds[i] = end;
|
||||
result.set(alloc.id, i);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!placed) {
|
||||
result.set(alloc.id, laneEnds.length);
|
||||
laneEnds.push(end);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface AutocompleteOption {
|
||||
id: string;
|
||||
label: string;
|
||||
sub?: string; // secondary label shown in smaller text
|
||||
}
|
||||
|
||||
interface AutocompleteInputProps {
|
||||
options: AutocompleteOption[];
|
||||
value: string; // selected id
|
||||
onChange: (id: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function normalize(s: string) {
|
||||
return s.toLowerCase().replace(/[-_\s]/g, "");
|
||||
}
|
||||
|
||||
function scoreMatch(option: AutocompleteOption, query: string): number {
|
||||
const q = normalize(query);
|
||||
const label = normalize(option.label);
|
||||
const sub = normalize(option.sub ?? "");
|
||||
if (label.startsWith(q) || sub.startsWith(q)) return 2;
|
||||
if (label.includes(q) || sub.includes(q)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function AutocompleteInput({ options, value, onChange, placeholder = "Search…", className }: AutocompleteInputProps) {
|
||||
const selected = options.find((o) => o.id === value);
|
||||
const [input, setInput] = useState(selected?.label ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Keep input text in sync when selected value changes externally
|
||||
useEffect(() => {
|
||||
setInput(selected?.label ?? "");
|
||||
}, [selected?.label]);
|
||||
|
||||
const filtered = input.trim() === "" || (selected && input === selected.label)
|
||||
? options.slice(0, 20)
|
||||
: options
|
||||
.map((o) => ({ o, score: scoreMatch(o, input) }))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ o }) => o)
|
||||
.slice(0, 20);
|
||||
|
||||
function select(opt: AutocompleteOption) {
|
||||
onChange(opt.id);
|
||||
setInput(opt.label);
|
||||
setOpen(false);
|
||||
setActiveIdx(0);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
onChange("");
|
||||
setInput("");
|
||||
setOpen(true);
|
||||
setActiveIdx(0);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx((i) => Math.min(i + 1, filtered.length - 1)); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx((i) => Math.max(i - 1, 0)); }
|
||||
else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const opt = filtered[activeIdx];
|
||||
if (opt) select(opt);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
// Restore display text to match current selection
|
||||
setInput(selected?.label ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setInput(selected?.label ?? "");
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open, selected?.label]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className ?? ""}`}>
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setOpen(true);
|
||||
setActiveIdx(0);
|
||||
// Clear selection if user edits away from selected label
|
||||
if (selected && e.target.value !== selected.label) onChange("");
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="w-full px-3 py-2 pr-7 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 leading-none text-base"
|
||||
aria-label="Clear"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && filtered.length > 0 && (
|
||||
<ul
|
||||
className="absolute z-50 left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden max-h-56 overflow-y-auto"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{filtered.map((opt, idx) => (
|
||||
<li key={opt.id}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); select(opt); }}
|
||||
className={`w-full text-left px-3 py-2 text-sm flex items-baseline gap-2 transition-colors ${
|
||||
idx === activeIdx
|
||||
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300"
|
||||
: "text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{opt.label}</span>
|
||||
{opt.sub && <span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">{opt.sub}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
|
||||
interface BatchAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "danger";
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface BatchActionBarProps {
|
||||
count: number;
|
||||
actions: BatchAction[];
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function BatchActionBar({ count, actions, onClear }: BatchActionBarProps) {
|
||||
if (count === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-72 right-6 z-40 flex items-center gap-4 bg-gray-900 text-white px-5 py-3 rounded-xl shadow-2xl border border-gray-700">
|
||||
<span className="text-sm font-medium shrink-0">
|
||||
{count} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 text-sm font-medium rounded-lg transition-colors disabled:opacity-50",
|
||||
action.variant === "danger"
|
||||
? "bg-red-600 hover:bg-red-700 text-white"
|
||||
: "bg-white/10 hover:bg-white/20 text-white",
|
||||
)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import type { ColumnDef } from "@planarchy/shared";
|
||||
|
||||
interface ColumnTogglePanelProps {
|
||||
allColumns: ColumnDef[];
|
||||
visibleKeys: string[];
|
||||
onSetVisible: (keys: string[]) => void;
|
||||
defaultKeys: string[];
|
||||
}
|
||||
|
||||
export function ColumnTogglePanel({
|
||||
allColumns,
|
||||
visibleKeys,
|
||||
onSetVisible,
|
||||
defaultKeys,
|
||||
}: ColumnTogglePanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
const dragKey = useRef<string | null>(null);
|
||||
|
||||
function toggle(key: string) {
|
||||
const col = allColumns.find((c) => c.key === key);
|
||||
if (!col?.hideable) return; // always-visible columns can't be toggled
|
||||
const next = visibleKeys.includes(key)
|
||||
? visibleKeys.filter((k) => k !== key)
|
||||
: [...visibleKeys, key];
|
||||
onSetVisible(next);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
onSetVisible(defaultKeys);
|
||||
}
|
||||
|
||||
const reorder = useCallback((fromKey: string, toKey: string) => {
|
||||
if (fromKey === toKey) return;
|
||||
const next = [...visibleKeys];
|
||||
const from = next.indexOf(fromKey);
|
||||
const to = next.indexOf(toKey);
|
||||
if (from === -1 || to === -1) return;
|
||||
next.splice(from, 1);
|
||||
next.splice(to, 0, fromKey);
|
||||
onSetVisible(next);
|
||||
}, [visibleKeys, onSetVisible]);
|
||||
|
||||
const builtins = allColumns.filter((c) => !c.isCustom);
|
||||
const customs = allColumns.filter((c) => c.isCustom);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={panelRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title="Toggle columns"
|
||||
className={`p-1.5 rounded-lg border text-sm transition-colors ${
|
||||
open
|
||||
? "border-brand-400 bg-brand-50 text-brand-700"
|
||||
: "border-gray-300 text-gray-500 hover:border-gray-400 hover:text-gray-700"
|
||||
}`}
|
||||
aria-label="Column visibility"
|
||||
>
|
||||
{/* columns icon */}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<rect x="1" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
|
||||
<rect x="6" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
|
||||
<rect x="11" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-52 bg-white border border-gray-200 rounded-xl shadow-xl py-2">
|
||||
<div className="px-3 pb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Columns</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="text-xs text-brand-600 hover:text-brand-800"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{builtins.map((col) => {
|
||||
const isVisible = visibleKeys.includes(col.key);
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
draggable={col.hideable && isVisible}
|
||||
onDragStart={() => { dragKey.current = col.key; }}
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 ${
|
||||
!col.hideable ? "opacity-50" : "cursor-grab"
|
||||
}`}
|
||||
>
|
||||
{col.hideable && isVisible && (
|
||||
<span className="text-gray-300 text-xs select-none">⠿</span>
|
||||
)}
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isVisible}
|
||||
onChange={() => toggle(col.key)}
|
||||
disabled={!col.hideable}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{col.label}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{customs.length > 0 && (
|
||||
<>
|
||||
<div className="my-1 border-t border-gray-100" />
|
||||
<p className="px-3 py-1 text-xs text-gray-400 font-medium">Custom Fields</p>
|
||||
{customs.map((col) => {
|
||||
const isVisible = visibleKeys.includes(col.key);
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
draggable={isVisible}
|
||||
onDragStart={() => { dragKey.current = col.key; }}
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
|
||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-grab"
|
||||
>
|
||||
{isVisible && <span className="text-gray-300 text-xs select-none">⠿</span>}
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isVisible}
|
||||
onChange={() => toggle(col.key)}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{col.label}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: "default" | "danger";
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
variant = "default",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div className="px-6 py-5">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-600">{message}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={
|
||||
variant === "danger"
|
||||
? "px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
|
||||
: "px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors"
|
||||
}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { FieldType } from "@planarchy/shared";
|
||||
import type { BlueprintFieldDefinition } from "@planarchy/shared";
|
||||
import type { CustomFieldFilter } from "~/hooks/useFilters.js";
|
||||
|
||||
interface Props {
|
||||
/** Filterable field definitions from global blueprints */
|
||||
filterableFields: (BlueprintFieldDefinition & { blueprintName: string })[];
|
||||
activeFilters: CustomFieldFilter[];
|
||||
onSetFilter: (key: string, value: string, type: FieldType) => void;
|
||||
}
|
||||
|
||||
const INPUT_CLS =
|
||||
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white";
|
||||
|
||||
export function CustomFieldFilterBar({ filterableFields, activeFilters, onSetFilter }: Props) {
|
||||
if (filterableFields.length === 0) return null;
|
||||
|
||||
function getValue(key: string) {
|
||||
return activeFilters.find((f) => f.key === key)?.value ?? "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">Custom:</span>
|
||||
{filterableFields.map((field) => {
|
||||
const value = getValue(field.key);
|
||||
|
||||
if (field.type === FieldType.BOOLEAN) {
|
||||
return (
|
||||
<select
|
||||
key={field.key}
|
||||
value={value}
|
||||
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
|
||||
className={INPUT_CLS}
|
||||
aria-label={field.label}
|
||||
>
|
||||
<option value="">{field.label}: any</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === FieldType.SELECT && field.options && field.options.length > 0) {
|
||||
return (
|
||||
<select
|
||||
key={field.key}
|
||||
value={value}
|
||||
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
|
||||
className={INPUT_CLS}
|
||||
aria-label={field.label}
|
||||
>
|
||||
<option value="">{field.label}: any</option>
|
||||
{field.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label || opt.value}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === FieldType.NUMBER) {
|
||||
return (
|
||||
<input
|
||||
key={field.key}
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
|
||||
placeholder={field.label}
|
||||
className={`${INPUT_CLS} w-32`}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DATE) {
|
||||
return (
|
||||
<input
|
||||
key={field.key}
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
|
||||
className={INPUT_CLS}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TEXT, TEXTAREA, URL, EMAIL, MULTI_SELECT — text search
|
||||
return (
|
||||
<input
|
||||
key={field.key}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
|
||||
placeholder={field.label}
|
||||
className={`${INPUT_CLS} w-40`}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DateInput — always displays dd/mm/yyyy regardless of browser/OS locale.
|
||||
*
|
||||
* - Text field shows and accepts dd/mm/yyyy (auto-inserts slashes while typing)
|
||||
* - Calendar icon opens a hidden <input type="date"> for native picker
|
||||
* - Internal value / onChange contract: yyyy-mm-dd strings (same as <input type="date">)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
// ── Conversion helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function isoToDisplay(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const [y, m, d] = iso.split("-");
|
||||
if (!y || !m || !d) return "";
|
||||
return `${d}/${m}/${y}`;
|
||||
}
|
||||
|
||||
function displayToISO(display: string): string {
|
||||
const digits = display.replace(/\D/g, "");
|
||||
if (digits.length !== 8) return "";
|
||||
const d = digits.slice(0, 2);
|
||||
const m = digits.slice(2, 4);
|
||||
const y = digits.slice(4, 8);
|
||||
if (parseInt(d) < 1 || parseInt(d) > 31) return "";
|
||||
if (parseInt(m) < 1 || parseInt(m) > 12) return "";
|
||||
if (parseInt(y) < 1900 || parseInt(y) > 2100) return "";
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
// Auto-insert slashes as the user types
|
||||
function autoSlash(raw: string): string {
|
||||
const digits = raw.replace(/\D/g, "").slice(0, 8);
|
||||
if (digits.length <= 2) return digits;
|
||||
if (digits.length <= 4) return `${digits.slice(0, 2)}/${digits.slice(2)}`;
|
||||
return `${digits.slice(0, 2)}/${digits.slice(2, 4)}/${digits.slice(4)}`;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DateInputProps {
|
||||
value: string; // yyyy-mm-dd (empty string = unset)
|
||||
onChange: (v: string) => void; // called with yyyy-mm-dd when valid
|
||||
id?: string;
|
||||
className?: string;
|
||||
required?: boolean;
|
||||
min?: string; // yyyy-mm-dd
|
||||
max?: string; // yyyy-mm-dd
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DateInput({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
className,
|
||||
required,
|
||||
min,
|
||||
max,
|
||||
disabled,
|
||||
}: DateInputProps) {
|
||||
const [display, setDisplay] = useState(isoToDisplay(value));
|
||||
const hiddenRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync display when value is changed externally
|
||||
useEffect(() => {
|
||||
setDisplay(isoToDisplay(value));
|
||||
}, [value]);
|
||||
|
||||
function handleTextChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const formatted = autoSlash(e.target.value);
|
||||
setDisplay(formatted);
|
||||
const iso = displayToISO(formatted);
|
||||
if (iso) onChange(iso);
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Re-normalise display to canonical format on blur
|
||||
const iso = displayToISO(display);
|
||||
if (iso) {
|
||||
setDisplay(isoToDisplay(iso));
|
||||
} else if (display && display.replace(/\D/g, "").length < 8) {
|
||||
// Incomplete — keep as-is so user can fix it
|
||||
} else if (!display) {
|
||||
// Cleared
|
||||
setDisplay("");
|
||||
}
|
||||
}
|
||||
|
||||
function handleHiddenChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const iso = e.target.value; // yyyy-mm-dd from native picker
|
||||
if (iso) {
|
||||
setDisplay(isoToDisplay(iso));
|
||||
onChange(iso);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="dd/mm/yyyy"
|
||||
value={display}
|
||||
onChange={handleTextChange}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className={clsx("pr-8", className)}
|
||||
/>
|
||||
{/* Hidden native date input driven by the calendar icon */}
|
||||
<input
|
||||
ref={hiddenRef}
|
||||
type="date"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={handleHiddenChange}
|
||||
className="sr-only absolute inset-0 w-full opacity-0 pointer-events-none"
|
||||
/>
|
||||
{/* Calendar icon — clicking opens the hidden picker */}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={disabled}
|
||||
aria-label="Open date picker"
|
||||
onClick={() => hiddenRef.current?.showPicker()}
|
||||
className="absolute right-2 text-gray-400 hover:text-gray-600 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
interface DraggableTableRowProps {
|
||||
id: string;
|
||||
/** Shared ref across all rows in the table — holds the ID currently being dragged. */
|
||||
dragRef: React.MutableRefObject<string | null>;
|
||||
/**
|
||||
* Called when another row is dropped onto this row.
|
||||
* Receives the ID of the row that was dragged (not this row's ID).
|
||||
*/
|
||||
onDrop: (draggedId: string) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table row with a left-side drag handle for manual row reordering.
|
||||
*
|
||||
* Usage: replace <tr> with <DraggableTableRow>, add <th className="w-8 px-2" /> as the
|
||||
* first header cell. Each table shares one dragRef (useRef<string | null>(null)).
|
||||
*/
|
||||
export function DraggableTableRow({
|
||||
id,
|
||||
dragRef,
|
||||
onDrop,
|
||||
children,
|
||||
className = "",
|
||||
}: DraggableTableRowProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={`${className} ${isDragOver ? "border-t-2 border-brand-400" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (dragRef.current && dragRef.current !== id) setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (dragRef.current && dragRef.current !== id) {
|
||||
// Pass the dragged ID so the caller knows what moved where
|
||||
onDrop(dragRef.current);
|
||||
}
|
||||
dragRef.current = null;
|
||||
}}
|
||||
>
|
||||
{/* Drag handle — left-most cell */}
|
||||
<td
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
dragRef.current = id;
|
||||
// Semi-transparent ghost
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
dragRef.current = null;
|
||||
setIsDragOver(false);
|
||||
}}
|
||||
className="w-8 px-2 py-3 cursor-grab active:cursor-grabbing select-none text-gray-300 hover:text-gray-400 text-center"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
⠿
|
||||
</td>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
interface FilterBarProps {
|
||||
children: React.ReactNode;
|
||||
hasActiveFilters?: boolean;
|
||||
onClearFilters?: () => void;
|
||||
}
|
||||
|
||||
export function FilterBar({ children, hasActiveFilters, onClearFilters }: FilterBarProps) {
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
{children}
|
||||
{hasActiveFilters && onClearFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearFilters}
|
||||
className="px-3 py-2 text-sm text-gray-500 hover:text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface Chip {
|
||||
label: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
interface FilterChipsProps {
|
||||
chips: Chip[];
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
export function FilterChips({ chips, onClearAll }: FilterChipsProps) {
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
key={chip.label}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-brand-50 text-brand-700 border border-brand-200 px-2.5 py-0.5 text-xs"
|
||||
>
|
||||
{chip.label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={chip.onRemove}
|
||||
className="ml-0.5 hover:text-brand-900 transition-colors"
|
||||
aria-label={`Remove filter: ${chip.label}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
interface InfiniteScrollSentinelProps {
|
||||
onVisible: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function InfiniteScrollSentinel({ onVisible, isLoading }: InfiniteScrollSentinelProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting && !isLoading) onVisible();
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, [isLoading, onVisible]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="h-6 flex items-center justify-center">
|
||||
{isLoading && (
|
||||
<div className="w-4 h-4 border-2 border-brand-500 border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface InfoTooltipProps {
|
||||
content: React.ReactNode;
|
||||
/** Position relative to the trigger icon. Default: "top" */
|
||||
position?: "top" | "bottom";
|
||||
/** Extra width class, e.g. "w-72". Default: "w-60" */
|
||||
width?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small ℹ icon that shows a tooltip on hover / focus.
|
||||
* Rendered via a portal into document.body so it's never clipped by
|
||||
* ancestor overflow:hidden containers (table cells, widget cards, etc.).
|
||||
*/
|
||||
export function InfoTooltip({ content, position = "top", width = "w-60" }: InfoTooltipProps) {
|
||||
const [show, setShow] = useState(false);
|
||||
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
function computeCoords() {
|
||||
if (!btnRef.current) return;
|
||||
const rect = btnRef.current.getBoundingClientRect();
|
||||
if (position === "top") {
|
||||
setCoords({
|
||||
top: rect.top + window.scrollY - 8, // 8px gap + arrow
|
||||
left: rect.left + window.scrollX + rect.width / 2,
|
||||
});
|
||||
} else {
|
||||
setCoords({
|
||||
top: rect.bottom + window.scrollY + 8,
|
||||
left: rect.left + window.scrollX + rect.width / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleShow() {
|
||||
computeCoords();
|
||||
setShow(true);
|
||||
}
|
||||
|
||||
// Recompute on scroll/resize while shown so tooltip follows the trigger
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
const update = () => computeCoords();
|
||||
window.addEventListener("scroll", update, true);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", update, true);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [show]);
|
||||
|
||||
const tooltipStyle: React.CSSProperties =
|
||||
position === "top"
|
||||
? { position: "fixed", top: coords.top, left: coords.left, transform: "translate(-50%, -100%)" }
|
||||
: { position: "fixed", top: coords.top, left: coords.left, transform: "translateX(-50%)" };
|
||||
|
||||
const arrowClass =
|
||||
position === "top"
|
||||
? "top-full border-t-gray-900 border-l-transparent border-r-transparent border-b-transparent border-l-4 border-r-4 border-t-4 border-b-0"
|
||||
: "bottom-full border-b-gray-900 border-l-transparent border-r-transparent border-t-transparent border-l-4 border-r-4 border-b-4 border-t-0";
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex items-center">
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
onMouseEnter={handleShow}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
onFocus={handleShow}
|
||||
onBlur={() => setShow(false)}
|
||||
className="ml-1 w-3.5 h-3.5 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-300 text-[9px] font-bold flex items-center justify-center hover:bg-gray-300 dark:hover:bg-gray-500 cursor-help flex-shrink-0 leading-none"
|
||||
aria-label="More information"
|
||||
>
|
||||
i
|
||||
</button>
|
||||
|
||||
{show &&
|
||||
createPortal(
|
||||
<div
|
||||
style={tooltipStyle}
|
||||
className={`z-[9999] ${width} bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-xl pointer-events-none`}
|
||||
>
|
||||
{content}
|
||||
<span className={`absolute left-1/2 -translate-x-1/2 w-0 h-0 ${arrowClass}`} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Thin brand-colored progress bar at the top of the page.
|
||||
* Animates to 100% on route change, then fades out.
|
||||
* Pure CSS animation — no external dependency.
|
||||
*/
|
||||
export function NavProgressBar() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [width, setWidth] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevPathRef = useRef<string | null>(null);
|
||||
|
||||
// Detect link clicks to start the bar early
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
const target = (e.target as Element).closest("a");
|
||||
if (!target) return;
|
||||
const href = target.getAttribute("href");
|
||||
if (!href || href.startsWith("http") || href.startsWith("#") || href.startsWith("mailto")) return;
|
||||
// Internal navigation — start bar
|
||||
setVisible(true);
|
||||
setWidth(60); // jump to 60% immediately, await route change for completion
|
||||
}
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => document.removeEventListener("click", handleClick);
|
||||
}, []);
|
||||
|
||||
// Complete bar when route actually changes
|
||||
useEffect(() => {
|
||||
const current = pathname + searchParams.toString();
|
||||
if (prevPathRef.current !== null && prevPathRef.current !== current) {
|
||||
// Route changed — complete the bar
|
||||
setWidth(100);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
setVisible(false);
|
||||
setWidth(0);
|
||||
}, 350);
|
||||
}
|
||||
prevPathRef.current = current;
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
|
||||
|
||||
if (!visible && width === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="fixed top-0 left-0 right-0 z-[9999] h-0.5 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-brand-500 transition-all ease-out"
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
transitionDuration: width === 100 ? "200ms" : "400ms",
|
||||
opacity: visible ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||
import type { ProjectStatus } from "@planarchy/shared";
|
||||
|
||||
interface ProjectComboboxProps {
|
||||
value: string | null;
|
||||
onChange: (id: string | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
status?: ProjectStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProjectCombobox({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search project…",
|
||||
disabled = false,
|
||||
status,
|
||||
className = "",
|
||||
}: ProjectComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data } = trpc.project.list.useQuery(
|
||||
{ search: debouncedSearch || undefined, limit: 15, ...(status ? { status } : {}) },
|
||||
{ enabled: open, staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const projects = data?.projects ?? [];
|
||||
|
||||
const { data: allData } = trpc.project.list.useQuery(
|
||||
{ limit: 500 },
|
||||
{ enabled: !!value && !open, staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
if (!value) return "";
|
||||
const fromOpen = projects.find((p) => p.id === value);
|
||||
if (fromOpen) return `${fromOpen.shortCode} — ${fromOpen.name}`;
|
||||
const fromAll = allData?.projects.find((p) => p.id === value);
|
||||
if (fromAll) return `${fromAll.shortCode} — ${fromAll.name}`;
|
||||
return value;
|
||||
}, [value, projects, allData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
function handleOpen() {
|
||||
if (disabled) return;
|
||||
setOpen(true);
|
||||
setSearch("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function select(id: string | null) {
|
||||
onChange(id);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
disabled={disabled}
|
||||
className={`w-full text-left px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
open ? "border-brand-500 ring-2 ring-brand-500" : "hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-400"}>
|
||||
{selectedLabel || placeholder}
|
||||
</span>
|
||||
{value && !disabled && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onMouseDown={(e) => { e.stopPropagation(); select(null); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") select(null); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-lg leading-none"
|
||||
aria-label="Clear"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
|
||||
<div className="p-2 border-b border-gray-100">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Type to search…"
|
||||
className="w-full px-2 py-1 text-sm border-0 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<ul className="max-h-52 overflow-y-auto py-1">
|
||||
{projects.length === 0 ? (
|
||||
<li className="px-3 py-2 text-sm text-gray-400">No results</li>
|
||||
) : (
|
||||
projects.map((p) => (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={() => select(p.id)}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 ${
|
||||
p.id === value ? "bg-brand-50 text-brand-700 font-medium" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-xs text-gray-400 mr-1.5">{p.shortCode}</span>
|
||||
<span>{p.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||
|
||||
interface ResourceComboboxProps {
|
||||
value: string | null;
|
||||
onChange: (id: string | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
isActive?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ResourceCombobox({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search resource…",
|
||||
disabled = false,
|
||||
isActive = true,
|
||||
className = "",
|
||||
}: ResourceComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data } = trpc.resource.list.useQuery(
|
||||
{ search: debouncedSearch || undefined, limit: 15, isActive },
|
||||
{ enabled: open, staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const resources = data?.resources ?? [];
|
||||
|
||||
// Resolve display name for currently selected value
|
||||
const { data: selectedData } = trpc.resource.list.useQuery(
|
||||
{ search: undefined, limit: 500, isActive: undefined as unknown as boolean },
|
||||
{ enabled: !!value && !open, staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
if (!value) return "";
|
||||
const fromOpen = resources.find((r) => r.id === value);
|
||||
if (fromOpen) return `${fromOpen.displayName} (${fromOpen.eid})`;
|
||||
const fromSelected = selectedData?.resources.find((r) => r.id === value);
|
||||
if (fromSelected) return `${fromSelected.displayName} (${fromSelected.eid})`;
|
||||
return value;
|
||||
}, [value, resources, selectedData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
function handleOpen() {
|
||||
if (disabled) return;
|
||||
setOpen(true);
|
||||
setSearch("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function select(id: string | null) {
|
||||
onChange(id);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
disabled={disabled}
|
||||
className={`w-full text-left px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
open ? "border-brand-500 ring-2 ring-brand-500" : "hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-400"}>
|
||||
{selectedLabel || placeholder}
|
||||
</span>
|
||||
{value && !disabled && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onMouseDown={(e) => { e.stopPropagation(); select(null); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") select(null); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-lg leading-none"
|
||||
aria-label="Clear"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
|
||||
<div className="p-2 border-b border-gray-100">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Type to search…"
|
||||
className="w-full px-2 py-1 text-sm border-0 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<ul className="max-h-52 overflow-y-auto py-1">
|
||||
{resources.length === 0 ? (
|
||||
<li className="px-3 py-2 text-sm text-gray-400">No results</li>
|
||||
) : (
|
||||
resources.map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={() => select(r.id)}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 ${
|
||||
r.id === value ? "bg-brand-50 text-brand-700 font-medium" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span>{r.displayName}</span>
|
||||
<span className="ml-1.5 text-xs text-gray-400">{r.eid}</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface SkillTagInputProps {
|
||||
value: string[];
|
||||
onChange: (skills: string[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SkillTagInput({ value, onChange, placeholder = "Add skill…", className }: SkillTagInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIdx, setActiveIdx] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: analytics } = trpc.resource.getSkillsAnalytics.useQuery(undefined, {
|
||||
staleTime: 120_000,
|
||||
});
|
||||
|
||||
const aggregated: { skill: string }[] = analytics?.aggregated ?? [];
|
||||
const suggestions: string[] = aggregated
|
||||
.map((a) => a.skill)
|
||||
.filter((s) => !value.includes(s) && s.toLowerCase().includes(input.toLowerCase()))
|
||||
.slice(0, 8);
|
||||
|
||||
function addSkill(skill: string) {
|
||||
const trimmed = skill.trim();
|
||||
if (trimmed && !value.includes(trimmed)) {
|
||||
onChange([...value, trimmed]);
|
||||
}
|
||||
setInput("");
|
||||
setOpen(false);
|
||||
setActiveIdx(-1);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function removeSkill(skill: string) {
|
||||
onChange(value.filter((s) => s !== skill));
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (activeIdx >= 0 && suggestions[activeIdx]) {
|
||||
addSkill(suggestions[activeIdx]);
|
||||
} else if (input.trim()) {
|
||||
addSkill(input);
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIdx((i) => Math.min(i + 1, suggestions.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIdx((i) => Math.max(i - 1, -1));
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setActiveIdx(-1);
|
||||
} else if (e.key === "Backspace" && input === "" && value.length > 0) {
|
||||
onChange(value.slice(0, -1));
|
||||
}
|
||||
}
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className ?? ""}`}>
|
||||
{/* Tags + input row */}
|
||||
<div
|
||||
className="min-h-[42px] flex flex-wrap gap-1.5 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 cursor-text focus-within:ring-2 focus-within:ring-brand-500 focus-within:border-transparent"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{value.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 bg-brand-100 dark:bg-brand-900/40 text-brand-800 dark:text-brand-300 text-xs font-medium rounded-full"
|
||||
>
|
||||
{skill}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSkill(skill)}
|
||||
className="hover:text-brand-600 dark:hover:text-brand-200 leading-none"
|
||||
aria-label={`Remove ${skill}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setOpen(true); setActiveIdx(-1); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={value.length === 0 ? placeholder : ""}
|
||||
className="flex-1 min-w-[120px] outline-none bg-transparent text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && suggestions.length > 0 && (
|
||||
<ul
|
||||
className="absolute z-50 left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden max-h-48 overflow-y-auto"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{suggestions.map((skill, idx) => (
|
||||
<li key={skill}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); addSkill(skill); }}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
|
||||
idx === activeIdx
|
||||
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300"
|
||||
: "text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{skill}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { InfoTooltip } from "./InfoTooltip.js";
|
||||
|
||||
interface SortIconProps {
|
||||
dir: "asc" | "desc" | null;
|
||||
}
|
||||
|
||||
function SortIcon({ dir }: SortIconProps) {
|
||||
return (
|
||||
<span className="inline-flex flex-col leading-none ml-0.5">
|
||||
<svg width="8" height="12" viewBox="0 0 8 12" fill="none" aria-hidden>
|
||||
{/* Up chevron */}
|
||||
<path
|
||||
d="M1 5L4 2L7 5"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={dir === "asc" ? "stroke-brand-600" : "stroke-gray-300"}
|
||||
/>
|
||||
{/* Down chevron */}
|
||||
<path
|
||||
d="M1 7L4 10L7 7"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={dir === "desc" ? "stroke-brand-600" : "stroke-gray-300"}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableColumnHeaderProps {
|
||||
label: string;
|
||||
field: string;
|
||||
sortField: string | null;
|
||||
sortDir: "asc" | "desc" | null;
|
||||
onSort: (field: string) => void;
|
||||
className?: string;
|
||||
align?: "left" | "right" | "center";
|
||||
tooltip?: string;
|
||||
tooltipWidth?: string;
|
||||
}
|
||||
|
||||
export function SortableColumnHeader({
|
||||
label,
|
||||
field,
|
||||
sortField,
|
||||
sortDir,
|
||||
onSort,
|
||||
className = "",
|
||||
align = "left",
|
||||
tooltip,
|
||||
tooltipWidth,
|
||||
}: SortableColumnHeaderProps) {
|
||||
const activeDir = sortField === field ? sortDir : null;
|
||||
const alignClass = align === "right" ? "justify-end" : align === "center" ? "justify-center" : "justify-start";
|
||||
|
||||
return (
|
||||
<th className={`px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider ${className}`}>
|
||||
<div className={`flex items-center gap-0.5 ${alignClass}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(field)}
|
||||
className="flex items-center gap-0.5 hover:text-gray-700 transition-colors group"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<SortIcon dir={activeDir} />
|
||||
</button>
|
||||
{tooltip && <InfoTooltip content={tooltip} {...(tooltipWidth !== undefined ? { width: tooltipWidth } : {})} />}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface BalanceCardProps {
|
||||
resourceId: string;
|
||||
year?: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function BalanceCard({ resourceId, year = new Date().getFullYear(), compact = false }: BalanceCardProps) {
|
||||
const { data: balance, isLoading } = trpc.entitlement.getBalance.useQuery(
|
||||
{ resourceId, year },
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 animate-pulse">
|
||||
<div className="h-4 bg-gray-100 dark:bg-gray-700 rounded w-1/3 mb-3" />
|
||||
<div className="h-8 bg-gray-100 dark:bg-gray-700 rounded w-1/2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!balance) return null;
|
||||
|
||||
const pct = balance.entitledDays > 0
|
||||
? Math.round((balance.usedDays / balance.entitledDays) * 100)
|
||||
: 0;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{balance.remainingDays}d remaining</span>
|
||||
<span className="text-gray-400 dark:text-gray-600">·</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">{balance.usedDays}d used of {balance.entitledDays}d</span>
|
||||
{balance.pendingDays > 0 && (
|
||||
<>
|
||||
<span className="text-gray-400 dark:text-gray-600">·</span>
|
||||
<span className="text-amber-600">{balance.pendingDays}d pending</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Vacation Balance {year}
|
||||
</h3>
|
||||
{balance.carryoverDays > 0 && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">+{balance.carryoverDays}d carried over</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Stat label="Entitled" value={balance.entitledDays} color="text-gray-900" />
|
||||
<Stat label="Used" value={balance.usedDays} color="text-gray-600" />
|
||||
<Stat label="Pending" value={balance.pendingDays} color="text-amber-600" />
|
||||
<Stat label="Remaining" value={balance.remainingDays} color={balance.remainingDays < 5 ? "text-red-600" : "text-emerald-600"} />
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="relative h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-emerald-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(100, pct)}%` }}
|
||||
/>
|
||||
{balance.pendingDays > 0 && (
|
||||
<div
|
||||
className="absolute inset-y-0 bg-amber-400 rounded-full"
|
||||
style={{
|
||||
left: `${Math.min(100, pct)}%`,
|
||||
width: `${Math.min(100 - pct, Math.round((balance.pendingDays / balance.entitledDays) * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{balance.sickDays > 0 && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{balance.sickDays} sick day{balance.sickDays !== 1 ? "s" : ""} recorded (not deducted from annual leave)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<p className={`text-xl font-bold ${color}`}>{value}</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
export function EntitlementManager() {
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [bulkDays, setBulkDays] = useState(28);
|
||||
const [bulkResult, setBulkResult] = useState<number | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const bulkSetMutation = trpc.entitlement.bulkSet.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
setBulkResult(data.updated);
|
||||
await utils.entitlement.getBalance.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: summary, isLoading } = trpc.entitlement.getYearSummary.useQuery(
|
||||
{ year },
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-5">
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Vacation Entitlement Manager</h3>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap gap-4 items-end">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Year</label>
|
||||
<input
|
||||
type="number"
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value, 10))}
|
||||
min={2020}
|
||||
max={2030}
|
||||
className="w-24 px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Default Days (bulk set)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={bulkDays}
|
||||
onChange={(e) => setBulkDays(parseInt(e.target.value, 10))}
|
||||
min={0}
|
||||
max={365}
|
||||
className="w-24 px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBulkResult(null);
|
||||
bulkSetMutation.mutate({ year, entitledDays: bulkDays });
|
||||
}}
|
||||
disabled={bulkSetMutation.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{bulkSetMutation.isPending ? "Setting…" : "Bulk Set All Resources"}
|
||||
</button>
|
||||
{bulkResult !== null && (
|
||||
<span className="text-sm text-emerald-600 dark:text-emerald-400">Updated {bulkResult} resources</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Year summary table */}
|
||||
<div className="overflow-hidden rounded-lg border border-gray-100 dark:border-gray-700">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-gray-400">Loading…</div>
|
||||
) : !summary?.length ? (
|
||||
<div className="p-6 text-center text-sm text-gray-400">No resources found.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
|
||||
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Resource</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chapter</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Entitled <InfoTooltip content="Total vacation days granted to this resource for the selected year." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Carryover <InfoTooltip content="Unused days carried over from the previous year." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Used <InfoTooltip content="Days already consumed by APPROVED vacations that have passed." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Pending <InfoTooltip content="Days reserved by APPROVED future vacations not yet started." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Remaining <InfoTooltip content="Entitled + Carryover − Used − Pending. Shown in red if fewer than 5 days remain." />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50 dark:divide-gray-700">
|
||||
{summary.map((row) => (
|
||||
<tr key={row.resourceId} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{row.displayName}</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">({row.eid})</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400">{row.chapter ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.entitledDays}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-400 dark:text-gray-500">{row.carryoverDays}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.usedDays}</td>
|
||||
<td className="px-4 py-2.5 text-right text-amber-600">{row.pendingDays}</td>
|
||||
<td className={`px-4 py-2.5 text-right font-semibold ${row.remainingDays < 5 ? "text-red-600" : "text-emerald-600"}`}>
|
||||
{row.remainingDays}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { VacationStatus, VacationType } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { VacationModal } from "./VacationModal.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { BalanceCard } from "./BalanceCard.js";
|
||||
import { VacationCalendar } from "./VacationCalendar.js";
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
PENDING: "bg-amber-100 text-amber-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
||||
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
REJECTED: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||
CANCELLED: "bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400",
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
ANNUAL: "Annual Leave",
|
||||
SICK: "Sick Leave",
|
||||
PUBLIC_HOLIDAY: "Public Holiday",
|
||||
OTHER: "Other",
|
||||
};
|
||||
|
||||
export function MyVacationsClient() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Find resource linked to current user
|
||||
const { data: myResource } = trpc.resource.getMyResource.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const resourceId = myResource?.id;
|
||||
|
||||
const { data: vacations, isLoading, refetch } = trpc.vacation.list.useQuery(
|
||||
{ resourceId, limit: 200 },
|
||||
{ enabled: !!resourceId, staleTime: 15_000 },
|
||||
);
|
||||
|
||||
const cancelMutation = trpc.vacation.cancel.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.vacation.list.invalidate();
|
||||
await utils.entitlement.getBalance.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const vacationList = vacations ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">My Vacations</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage your personal vacation requests</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(true)}
|
||||
disabled={!resourceId}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
+ Request Vacation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!resourceId && (
|
||||
<div className="rounded-xl bg-amber-50 border border-amber-200 p-4 text-sm text-amber-700">
|
||||
Your account is not linked to a resource. Please contact an administrator.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Balance card */}
|
||||
{resourceId && (
|
||||
<BalanceCard resourceId={resourceId} />
|
||||
)}
|
||||
|
||||
{/* Calendar */}
|
||||
{resourceId && vacationList.length > 0 && (
|
||||
<VacationCalendar vacations={vacationList} />
|
||||
)}
|
||||
|
||||
{/* Vacation list */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400">Loading…</div>
|
||||
) : vacationList.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400">No vacation requests yet.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
|
||||
Type <InfoTooltip content="ANNUAL = paid annual leave · SICK = sick leave · PUBLIC_HOLIDAY = public holiday · OTHER = other leave types." />
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">Start</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">End</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Days <InfoTooltip content="Calendar days from start to end date (inclusive). Shows 0.5 for half-day requests (½ indicator on start date)." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
|
||||
Status <InfoTooltip content="PENDING = awaiting manager approval · APPROVED = confirmed leave · REJECTED = declined by manager · CANCELLED = withdrawn by employee." />
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
|
||||
Note <InfoTooltip content="Your note on the request, or the manager's rejection reason if declined." />
|
||||
</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50 dark:divide-gray-700">
|
||||
{vacationList.map((v) => {
|
||||
const start = new Date(v.startDate);
|
||||
const end = new Date(v.endDate);
|
||||
const days = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1;
|
||||
const status = v.status as string;
|
||||
const type = v.type as string;
|
||||
const vWithExtra = v as unknown as { rejectionReason?: string | null; isHalfDay?: boolean };
|
||||
|
||||
return (
|
||||
<tr key={v.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{TYPE_LABELS[type] ?? type}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{start.toLocaleDateString("en-GB")}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{end.toLocaleDateString("en-GB")}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{vWithExtra.isHalfDay ? "0.5" : days}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[status] ?? ""}`}>
|
||||
{status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-400 dark:text-gray-500 text-xs max-w-[200px]">
|
||||
{vWithExtra.rejectionReason ? (
|
||||
<span className="text-red-500">{vWithExtra.rejectionReason}</span>
|
||||
) : (v.note ?? "—")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{(status === VacationStatus.PENDING || status === VacationStatus.APPROVED) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancelMutation.mutate({ id: v.id })}
|
||||
disabled={cancelMutation.isPending}
|
||||
className="text-xs text-gray-400 hover:text-red-600 underline disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showModal && resourceId && (
|
||||
<VacationModal
|
||||
resourceId={resourceId}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowModal(false);
|
||||
void refetch();
|
||||
void utils.entitlement.getBalance.invalidate();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { GERMAN_FEDERAL_STATES } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
export function PublicHolidayBatch() {
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [federalState, setFederalState] = useState("BY");
|
||||
const [chapter, setChapter] = useState("");
|
||||
const [replaceExisting, setReplaceExisting] = useState(false);
|
||||
const [result, setResult] = useState<{ created: number; holidays?: number; resources?: number } | null>(null);
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const resourceList = (resources?.resources ?? []) as Array<{ id: string; chapter?: string | null }>;
|
||||
const chapters = Array.from(
|
||||
new Set(resourceList.map((r) => r.chapter).filter(Boolean) as string[])
|
||||
).sort();
|
||||
|
||||
const mutation = trpc.vacation.batchCreatePublicHolidays.useMutation({
|
||||
onSuccess: (data) => setResult(data),
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setResult(null);
|
||||
mutation.mutate({
|
||||
year,
|
||||
federalState: federalState || undefined,
|
||||
chapter: chapter || undefined,
|
||||
replaceExisting,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Batch Create Public Holidays</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Creates public holidays as APPROVED vacation entries for all resources (or a chapter).
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Year</label>
|
||||
<input
|
||||
type="number"
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value, 10))}
|
||||
min={2020}
|
||||
max={2030}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Federal State</label>
|
||||
<select
|
||||
value={federalState}
|
||||
onChange={(e) => setFederalState(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">Federal only</option>
|
||||
{Object.entries(GERMAN_FEDERAL_STATES).map(([abbr, name]) => (
|
||||
<option key={abbr} value={abbr}>{name} ({abbr})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Chapter (optional)</label>
|
||||
<select
|
||||
value={chapter}
|
||||
onChange={(e) => setChapter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All chapters</option>
|
||||
{chapters.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={replaceExisting}
|
||||
onChange={(e) => setReplaceExisting(e.target.checked)}
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-brand-600"
|
||||
/>
|
||||
Replace existing public holidays on those dates
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Creating…" : "Create Public Holidays"}
|
||||
</button>
|
||||
|
||||
{result && (
|
||||
<span className="text-sm text-emerald-600 dark:text-emerald-400">
|
||||
Created {result.created} entries{result.holidays ? ` (${result.holidays} holidays × ${result.resources ?? 0} resources)` : ""}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{mutation.error && (
|
||||
<span className="text-sm text-red-500">{mutation.error.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { VacationStatus } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
ANNUAL: "bg-brand-500",
|
||||
SICK: "bg-red-400",
|
||||
PUBLIC_HOLIDAY: "bg-emerald-500",
|
||||
OTHER: "bg-purple-400",
|
||||
};
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
function isoDate(d: Date | string): string {
|
||||
const date = typeof d === "string" ? new Date(d) : d;
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function TeamCalendar() {
|
||||
const now = new Date();
|
||||
const [month, setMonth] = useState(now.getMonth());
|
||||
const [year, setYear] = useState(now.getFullYear());
|
||||
const [chapter, setChapter] = useState<string>("");
|
||||
|
||||
const firstDay = new Date(Date.UTC(year, month, 1));
|
||||
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
{ isActive: true, limit: 500, ...(chapter ? { chapter } : {}) },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const { data: vacations } = trpc.vacation.list.useQuery(
|
||||
{
|
||||
startDate: firstDay,
|
||||
endDate: new Date(Date.UTC(year, month + 1, 0)),
|
||||
limit: 500,
|
||||
},
|
||||
{ staleTime: 15_000 },
|
||||
);
|
||||
|
||||
// Distinct chapters for filter
|
||||
const chapters = Array.from(
|
||||
new Set((resources?.resources ?? []).map((r) => r.chapter).filter(Boolean) as string[])
|
||||
).sort();
|
||||
|
||||
const resourceList = resources?.resources ?? [];
|
||||
const vacationList = (vacations ?? []).filter(
|
||||
(v) => v.status !== VacationStatus.CANCELLED && v.status !== VacationStatus.REJECTED,
|
||||
);
|
||||
|
||||
// Build map: resourceId → date → vacation
|
||||
const vacationMap = new Map<string, Map<string, { type: string; status: string }>>();
|
||||
for (const v of vacationList) {
|
||||
const start = isoDate(v.startDate);
|
||||
const end = isoDate(v.endDate);
|
||||
let cur = start;
|
||||
while (cur <= end) {
|
||||
const resourceVacs = vacationMap.get(v.resourceId) ?? new Map();
|
||||
if (!resourceVacs.has(cur)) {
|
||||
resourceVacs.set(cur, { type: v.type, status: v.status });
|
||||
}
|
||||
vacationMap.set(v.resourceId, resourceVacs);
|
||||
// next day
|
||||
const d = new Date(cur);
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
cur = d.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
|
||||
function prevMonth() {
|
||||
if (month === 0) { setMonth(11); setYear(y => y - 1); }
|
||||
else setMonth(m => m - 1);
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
if (month === 11) { setMonth(0); setYear(y => y + 1); }
|
||||
else setMonth(m => m + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-700 flex-wrap">
|
||||
<button type="button" onClick={prevMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
|
||||
‹
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 min-w-[120px] text-center">
|
||||
{MONTH_NAMES[month]} {year}
|
||||
</span>
|
||||
<button type="button" onClick={nextMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
|
||||
›
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<select
|
||||
value={chapter}
|
||||
onChange={(e) => setChapter(e.target.value)}
|
||||
className="text-sm border border-gray-200 dark:border-gray-600 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All chapters</option>
|
||||
{chapters.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
|
||||
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-36 border-r border-gray-100 dark:border-gray-700">
|
||||
Resource
|
||||
</th>
|
||||
{days.map((d) => {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
const dow = new Date(dateStr).getUTCDay(); // 0=Sun, 6=Sat
|
||||
const isWeekend = dow === 0 || dow === 6;
|
||||
const isToday = dateStr === today;
|
||||
return (
|
||||
<th
|
||||
key={d}
|
||||
className={`px-1 py-2 text-center font-medium w-7 ${isWeekend ? "text-gray-300 dark:text-gray-600 bg-gray-50 dark:bg-gray-900" : isToday ? "text-brand-700 bg-brand-50" : "text-gray-500 dark:text-gray-400"}`}
|
||||
>
|
||||
{d}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50 dark:divide-gray-700">
|
||||
{resourceList.map((r) => {
|
||||
const rMap = vacationMap.get(r.id);
|
||||
return (
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="sticky left-0 z-10 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 px-3 py-1.5 border-r border-gray-100 dark:border-gray-700 font-medium text-gray-800 dark:text-gray-100 truncate max-w-[9rem]">
|
||||
{r.displayName}
|
||||
</td>
|
||||
{days.map((d) => {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
const vac = rMap?.get(dateStr);
|
||||
const dow = new Date(dateStr).getUTCDay();
|
||||
const isWeekend = dow === 0 || dow === 6;
|
||||
const isToday = dateStr === today;
|
||||
|
||||
let cellClass = "w-7 h-7";
|
||||
if (vac) {
|
||||
const color = TYPE_COLOR[vac.type] ?? "bg-gray-400";
|
||||
const opacity = vac.status === "PENDING" ? "opacity-50" : "";
|
||||
cellClass += ` ${color} ${opacity}`;
|
||||
} else if (isWeekend) {
|
||||
cellClass += " bg-gray-50 dark:bg-gray-900";
|
||||
} else if (isToday) {
|
||||
cellClass += " bg-brand-50";
|
||||
}
|
||||
|
||||
return (
|
||||
<td key={d} className="px-0.5 py-0.5">
|
||||
<div
|
||||
className={cellClass + " rounded-sm"}
|
||||
title={vac ? `${r.displayName}: ${vac.type} (${vac.status})` : undefined}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{resourceList.length === 0 && (
|
||||
<div className="p-8 text-center text-sm text-gray-400">No resources found.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
|
||||
{Object.entries(TYPE_COLOR).map(([type, color]) => (
|
||||
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className={`${color} w-3 h-3 rounded-sm inline-block`} />
|
||||
{type.replace("_", " ")}
|
||||
</span>
|
||||
))}
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
<span className="opacity-50 bg-brand-500 w-3 h-3 rounded-sm inline-block" />
|
||||
Pending
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { VacationStatus, VacationType } from "@planarchy/shared";
|
||||
|
||||
interface VacationEntry {
|
||||
id: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
type: string;
|
||||
status: string;
|
||||
resource?: { displayName: string; eid: string } | null;
|
||||
}
|
||||
|
||||
interface VacationCalendarProps {
|
||||
vacations: VacationEntry[];
|
||||
year?: number;
|
||||
initialMonth?: number; // 0-indexed
|
||||
}
|
||||
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
ANNUAL: "bg-brand-500",
|
||||
SICK: "bg-red-400",
|
||||
PUBLIC_HOLIDAY: "bg-emerald-400",
|
||||
OTHER: "bg-purple-400",
|
||||
};
|
||||
|
||||
const STATUS_OPACITY: Record<string, string> = {
|
||||
APPROVED: "opacity-100",
|
||||
PENDING: "opacity-60",
|
||||
REJECTED: "opacity-30",
|
||||
CANCELLED: "opacity-20",
|
||||
};
|
||||
|
||||
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
const MONTH_NAMES = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
|
||||
function isoDate(d: Date | string): string {
|
||||
const date = typeof d === "string" ? new Date(d) : d;
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(dateStr: string, n: number): string {
|
||||
const d = new Date(dateStr);
|
||||
d.setUTCDate(d.getUTCDate() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getDatesInRange(start: Date | string, end: Date | string): Set<string> {
|
||||
const dates = new Set<string>();
|
||||
let cur = isoDate(start);
|
||||
const last = isoDate(end);
|
||||
while (cur <= last) {
|
||||
dates.add(cur);
|
||||
cur = addDays(cur, 1);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
export function VacationCalendar({ vacations, year = new Date().getFullYear(), initialMonth = new Date().getMonth() }: VacationCalendarProps) {
|
||||
const [month, setMonth] = useState(initialMonth);
|
||||
const [currentYear, setCurrentYear] = useState(year);
|
||||
|
||||
function prevMonth() {
|
||||
if (month === 0) { setMonth(11); setCurrentYear(y => y - 1); }
|
||||
else setMonth(m => m - 1);
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
if (month === 11) { setMonth(0); setCurrentYear(y => y + 1); }
|
||||
else setMonth(m => m + 1);
|
||||
}
|
||||
|
||||
// Build a set of date → vacation entries for fast lookup
|
||||
const dateMap = new Map<string, VacationEntry[]>();
|
||||
for (const v of vacations) {
|
||||
if ([VacationStatus.CANCELLED, VacationStatus.REJECTED].includes(v.status as VacationStatus)) continue;
|
||||
const dates = getDatesInRange(v.startDate, v.endDate);
|
||||
for (const d of dates) {
|
||||
const existing = dateMap.get(d) ?? [];
|
||||
existing.push(v);
|
||||
dateMap.set(d, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Build calendar grid
|
||||
const firstDay = new Date(Date.UTC(currentYear, month, 1));
|
||||
const daysInMonth = new Date(Date.UTC(currentYear, month + 1, 0)).getUTCDate();
|
||||
// ISO weekday: Mon=1, Sun=7 → index 0-6
|
||||
const startOffset = (firstDay.getUTCDay() + 6) % 7; // Mon first
|
||||
|
||||
const cells: (number | null)[] = [
|
||||
...Array<null>(startOffset).fill(null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
// Pad to complete last row
|
||||
while (cells.length % 7 !== 0) cells.push(null);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700">
|
||||
<button type="button" onClick={prevMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
|
||||
‹
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{MONTH_NAMES[month]} {currentYear}
|
||||
</h3>
|
||||
<button type="button" onClick={nextMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day names */}
|
||||
<div className="grid grid-cols-7 border-b border-gray-100 dark:border-gray-700">
|
||||
{DAYS.map((d) => (
|
||||
<div key={d} className="px-2 py-1.5 text-center text-xs font-medium text-gray-400 dark:text-gray-500">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Days grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, idx) => {
|
||||
if (!day) {
|
||||
return <div key={`empty-${idx}`} className="p-1 min-h-[60px] border-b border-r border-gray-50 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-900/30" />;
|
||||
}
|
||||
|
||||
const dateStr = `${currentYear}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
const dayVacations = dateMap.get(dateStr) ?? [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const isToday = dateStr === today;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dateStr}
|
||||
className={`p-1 min-h-[60px] border-b border-r border-gray-50 dark:border-gray-700 ${isToday ? "bg-brand-50" : ""}`}
|
||||
>
|
||||
<span className={`text-xs font-medium block mb-1 ${isToday ? "text-brand-700" : "text-gray-500 dark:text-gray-400"}`}>
|
||||
{day}
|
||||
</span>
|
||||
<div className="space-y-0.5">
|
||||
{dayVacations.slice(0, 3).map((v) => {
|
||||
const colorClass = TYPE_COLOR[v.type] ?? "bg-gray-400";
|
||||
const opacityClass = STATUS_OPACITY[v.status] ?? "opacity-100";
|
||||
const name = v.resource?.displayName ?? "—";
|
||||
return (
|
||||
<div
|
||||
key={v.id + dateStr}
|
||||
className={`${colorClass} ${opacityClass} text-white text-xs px-1 rounded truncate`}
|
||||
title={`${name} — ${v.type} (${v.status})`}
|
||||
>
|
||||
{name.split(" ")[0]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{dayVacations.length > 3 && (
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 pl-1">+{dayVacations.length - 3}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
|
||||
{Object.entries(TYPE_COLOR).map(([type, color]) => (
|
||||
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className={`${color} w-3 h-3 rounded-sm inline-block`} />
|
||||
{type.replace("_", " ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { VacationStatus, VacationType } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { VacationModal } from "./VacationModal.js";
|
||||
import { TeamCalendar } from "./TeamCalendar.js";
|
||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
const STATUS_BADGE: Record<VacationStatus, string> = {
|
||||
PENDING: "bg-amber-100 text-amber-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
||||
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
REJECTED: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||
CANCELLED: "bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400",
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<VacationType, string> = {
|
||||
ANNUAL: "Annual Leave",
|
||||
SICK: "Sick Leave",
|
||||
PUBLIC_HOLIDAY: "Public Holiday",
|
||||
OTHER: "Other",
|
||||
};
|
||||
|
||||
type VacationStatusFilter = VacationStatus | "ALL";
|
||||
type VacationTypeFilter = VacationType | "ALL";
|
||||
type Tab = "list" | "team-calendar";
|
||||
|
||||
export function VacationClient() {
|
||||
const [tab, setTab] = useState<Tab>("list");
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<VacationStatusFilter>("ALL");
|
||||
const [typeFilter, setTypeFilter] = useState<VacationTypeFilter>("ALL");
|
||||
const [resourceFilter, setResourceFilter] = useState<string>("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchRejectReason, setBatchRejectReason] = useState("");
|
||||
const [showBatchRejectInput, setShowBatchRejectInput] = useState(false);
|
||||
|
||||
const { data: vacations, isLoading, error: vacationError, refetch } = trpc.vacation.list.useQuery(
|
||||
{
|
||||
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
|
||||
...(typeFilter !== "ALL" ? { type: typeFilter } : {}),
|
||||
...(resourceFilter ? { resourceId: resourceFilter } : {}),
|
||||
limit: 200,
|
||||
},
|
||||
{ staleTime: 15_000 },
|
||||
);
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const { data: pending } = trpc.vacation.getPendingApprovals.useQuery(undefined, {
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
function invalidateAll() {
|
||||
return Promise.all([
|
||||
utils.vacation.list.invalidate(),
|
||||
utils.vacation.getPendingApprovals.invalidate(),
|
||||
utils.entitlement.getBalance.invalidate(),
|
||||
]);
|
||||
}
|
||||
|
||||
const approveMutation = trpc.vacation.approve.useMutation({ onSuccess: invalidateAll });
|
||||
const rejectMutation = trpc.vacation.reject.useMutation({ onSuccess: invalidateAll });
|
||||
const cancelMutation = trpc.vacation.cancel.useMutation({ onSuccess: () => utils.vacation.list.invalidate() });
|
||||
const batchApproveMutation = trpc.vacation.batchApprove.useMutation({
|
||||
onSuccess: async () => {
|
||||
setSelected(new Set());
|
||||
await invalidateAll();
|
||||
},
|
||||
});
|
||||
const batchRejectMutation = trpc.vacation.batchReject.useMutation({
|
||||
onSuccess: async () => {
|
||||
setSelected(new Set());
|
||||
setShowBatchRejectInput(false);
|
||||
setBatchRejectReason("");
|
||||
await invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const resourceList = (resources?.resources ?? []) as unknown as Array<{ id: string; displayName: string; eid: string }>;
|
||||
const vacationList = vacations ?? [];
|
||||
const pendingList = pending ?? [];
|
||||
|
||||
const vacViewPrefs = useViewPrefs("vacations");
|
||||
const { sorted, sortField, sortDir, toggle } = useTableSort(vacationList, {
|
||||
initialField: vacViewPrefs.savedSort?.field ?? null,
|
||||
initialDir: vacViewPrefs.savedSort?.dir ?? null,
|
||||
onSortChange: (field, dir) => {
|
||||
vacViewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSort(field: string) {
|
||||
if (field === "resource") {
|
||||
toggle("resource", (v) => (v.resource as { displayName: string } | undefined)?.displayName ?? null);
|
||||
} else {
|
||||
toggle(field);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setStatusFilter("ALL");
|
||||
setTypeFilter("ALL");
|
||||
setResourceFilter("");
|
||||
}
|
||||
|
||||
const selectedResourceName = resourceFilter ? resourceList.find((r) => r.id === resourceFilter)?.displayName : null;
|
||||
|
||||
const chips = [
|
||||
...(statusFilter !== "ALL" ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("ALL") }] : []),
|
||||
...(typeFilter !== "ALL" ? [{ label: `Type: ${TYPE_LABELS[typeFilter]}`, onRemove: () => setTypeFilter("ALL") }] : []),
|
||||
...(resourceFilter ? [{ label: `Resource: ${selectedResourceName ?? resourceFilter}`, onRemove: () => setResourceFilter("") }] : []),
|
||||
];
|
||||
|
||||
const pendingIds = pendingList.map((v) => v.id);
|
||||
const selectedPending = [...selected].filter((id) => pendingIds.includes(id));
|
||||
const allPendingSelected = pendingIds.length > 0 && pendingIds.every((id) => selected.has(id));
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allPendingSelected) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(pendingIds));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Vacations</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage vacation requests and approvals</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||||
>
|
||||
+ Request Vacation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-gray-200 dark:border-gray-700">
|
||||
{(["list", "team-calendar"] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t
|
||||
? "border-brand-600 text-brand-700"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{t === "list" ? "List" : "Team Calendar"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "team-calendar" ? (
|
||||
<TeamCalendar />
|
||||
) : (
|
||||
<>
|
||||
{/* Pending approvals (manager view) */}
|
||||
{pendingList.length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-amber-800">
|
||||
Pending Approvals ({pendingList.length})
|
||||
</h2>
|
||||
{/* Batch controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs text-amber-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allPendingSelected}
|
||||
onChange={toggleSelectAll}
|
||||
className="rounded border-amber-300 text-amber-600"
|
||||
/>
|
||||
Select all
|
||||
</label>
|
||||
{selectedPending.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => batchApproveMutation.mutate({ ids: selectedPending })}
|
||||
disabled={batchApproveMutation.isPending}
|
||||
className="px-2 py-1 bg-emerald-600 text-white text-xs rounded hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
Approve {selectedPending.length}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBatchRejectInput((v) => !v)}
|
||||
className="px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700"
|
||||
>
|
||||
Reject {selectedPending.length}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Batch reject reason input */}
|
||||
{showBatchRejectInput && selectedPending.length > 0 && (
|
||||
<div className="mb-3 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rejection reason (optional)…"
|
||||
value={batchRejectReason}
|
||||
onChange={(e) => setBatchRejectReason(e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-amber-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
batchRejectMutation.mutate({
|
||||
ids: selectedPending,
|
||||
...(batchRejectReason.trim() ? { rejectionReason: batchRejectReason.trim() } : {}),
|
||||
})
|
||||
}
|
||||
disabled={batchRejectMutation.isPending}
|
||||
className="px-3 py-1.5 bg-red-600 text-white text-xs rounded hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
Confirm Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{pendingList.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg px-4 py-2 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(v.id)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(selected);
|
||||
if (e.target.checked) next.add(v.id);
|
||||
else next.delete(v.id);
|
||||
setSelected(next);
|
||||
}}
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-amber-600 shrink-0"
|
||||
/>
|
||||
<span className="font-medium text-sm text-gray-900 dark:text-gray-100">{v.resource.displayName}</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">({v.resource.eid})</span>
|
||||
<span className="mx-1 text-gray-300 dark:text-gray-600">·</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{TYPE_LABELS[v.type as VacationType]}</span>
|
||||
<span className="mx-1 text-gray-300 dark:text-gray-600">·</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date(v.startDate).toLocaleDateString("en-GB")} –{" "}
|
||||
{new Date(v.endDate).toLocaleDateString("en-GB")}
|
||||
</span>
|
||||
{v.note && (
|
||||
<span className="text-xs text-gray-400 ml-2 italic truncate">{v.note}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => approveMutation.mutate({ id: v.id })}
|
||||
disabled={approveMutation.isPending}
|
||||
className="px-3 py-1 bg-emerald-600 text-white text-xs rounded hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rejectMutation.mutate({ id: v.id })}
|
||||
disabled={rejectMutation.isPending}
|
||||
className="px-3 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as VacationStatusFilter)}
|
||||
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="ALL">All statuses</option>
|
||||
{Object.values(VacationStatus).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value as VacationTypeFilter)}
|
||||
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="ALL">All types</option>
|
||||
{Object.values(VacationType).map((t) => (
|
||||
<option key={t} value={t}>{TYPE_LABELS[t]}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={resourceFilter}
|
||||
onChange={(e) => setResourceFilter(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All resources</option>
|
||||
{resourceList.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.displayName} ({r.eid})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Filter chips */}
|
||||
{chips.length > 0 && (
|
||||
<FilterChips chips={chips} onClearAll={clearAll} />
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400">Loading…</div>
|
||||
) : vacationError ? (
|
||||
<div className="p-8 text-center text-sm text-red-500">Error: {vacationError.message}</div>
|
||||
) : vacationList.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400">No vacations found.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<SortableColumnHeader label="Resource" field="resource" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
|
||||
<th className="px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("type")}
|
||||
className="flex items-center gap-0.5 justify-start w-full hover:text-gray-700 transition-colors group"
|
||||
>
|
||||
Type
|
||||
</button>
|
||||
<InfoTooltip content="ANNUAL = paid annual leave · SICK = sick leave · PUBLIC_HOLIDAY = public holiday · OTHER = other leave types." />
|
||||
</span>
|
||||
</th>
|
||||
<SortableColumnHeader label="Start" field="startDate" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortableColumnHeader label="End" field="endDate" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
|
||||
<th className="px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("status")}
|
||||
className="flex items-center gap-0.5 justify-start w-full hover:text-gray-700 transition-colors group"
|
||||
>
|
||||
Status
|
||||
</button>
|
||||
<InfoTooltip content="PENDING = awaiting manager approval · APPROVED = confirmed leave · REJECTED = declined by manager · CANCELLED = withdrawn by employee." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
|
||||
Note / Reason <InfoTooltip content="Employee's leave note, or manager's rejection reason if status is REJECTED." />
|
||||
</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50 dark:divide-gray-700">
|
||||
{sorted.map((v) => {
|
||||
const type = v.type as VacationType;
|
||||
const status = v.status as VacationStatus;
|
||||
const resource = v.resource as { displayName: string; eid: string } | undefined;
|
||||
const vExtra = v as unknown as { rejectionReason?: string | null; isHalfDay?: boolean };
|
||||
return (
|
||||
<tr key={v.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{resource?.displayName ?? "—"}
|
||||
</span>
|
||||
{resource?.eid && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">({resource.eid})</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{TYPE_LABELS[type] ?? type}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{new Date(v.startDate).toLocaleDateString("en-GB")}
|
||||
{vExtra.isHalfDay && <span className="ml-1 text-xs text-gray-400 dark:text-gray-500">½</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{new Date(v.endDate).toLocaleDateString("en-GB")}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[status] ?? ""}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-400 dark:text-gray-500 text-xs max-w-xs truncate">
|
||||
{vExtra.rejectionReason ? (
|
||||
<span className="text-red-500">{vExtra.rejectionReason}</span>
|
||||
) : (v.note ?? "—")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-3">
|
||||
{status === VacationStatus.CANCELLED ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => approveMutation.mutate({ id: v.id })}
|
||||
disabled={approveMutation.isPending}
|
||||
className="text-xs text-emerald-600 dark:text-emerald-400 hover:text-emerald-800 underline disabled:opacity-50"
|
||||
>
|
||||
Re-approve
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancelMutation.mutate({ id: v.id })}
|
||||
disabled={cancelMutation.isPending}
|
||||
className="text-xs text-gray-400 hover:text-red-600 underline disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<VacationModal
|
||||
onClose={() => setShowModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowModal(false);
|
||||
void refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import { VacationType } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||
|
||||
const VACATION_TYPES = Object.values(VacationType);
|
||||
|
||||
const VACATION_TYPE_LABELS: Record<VacationType, string> = {
|
||||
ANNUAL: "Annual Leave",
|
||||
SICK: "Sick Leave",
|
||||
PUBLIC_HOLIDAY: "Public Holiday",
|
||||
OTHER: "Other",
|
||||
};
|
||||
|
||||
interface VacationModalProps {
|
||||
resourceId?: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function toDateInputValue(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toISOString().split("T")[0] ?? "";
|
||||
}
|
||||
|
||||
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
|
||||
const [resourceId, setResourceId] = useState(initialResourceId ?? "");
|
||||
const [type, setType] = useState<VacationType>(VacationType.ANNUAL);
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [isHalfDay, setIsHalfDay] = useState(false);
|
||||
const [halfDayPart, setHalfDayPart] = useState<"MORNING" | "AFTERNOON">("MORNING");
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const debouncedStart = useDebounce(startDate, 400);
|
||||
const debouncedEnd = useDebounce(endDate, 400);
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
// Team overlap: other resources in same chapter off in this period
|
||||
const { data: teamOverlap } = trpc.vacation.getTeamOverlap.useQuery(
|
||||
{
|
||||
resourceId: resourceId,
|
||||
startDate: debouncedStart ? new Date(debouncedStart) : new Date(),
|
||||
endDate: debouncedEnd ? new Date(debouncedEnd) : new Date(),
|
||||
},
|
||||
{
|
||||
enabled: !!resourceId && !!debouncedStart && !!debouncedEnd,
|
||||
staleTime: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
// Show existing vacations for this resource in the selected period
|
||||
const { data: existingVacations } = trpc.vacation.list.useQuery(
|
||||
{
|
||||
resourceId: resourceId || undefined,
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
endDate: endDate ? new Date(endDate) : undefined,
|
||||
},
|
||||
{ enabled: !!resourceId && !!startDate && !!endDate, staleTime: 10_000 },
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createMutation = trpc.vacation.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.vacation.list.invalidate();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err) => setServerError(err.message),
|
||||
});
|
||||
|
||||
const isPending = createMutation.isPending;
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setServerError(null);
|
||||
|
||||
if (!resourceId || !startDate || !endDate) {
|
||||
setServerError("Please fill in all required fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
if (end < start) {
|
||||
setServerError("End date must be on or after start date.");
|
||||
return;
|
||||
}
|
||||
|
||||
createMutation.mutate({
|
||||
resourceId,
|
||||
type,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
note: note || undefined,
|
||||
isHalfDay,
|
||||
...(isHalfDay ? { halfDayPart } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
||||
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||
const resourceList = resources?.resources ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Request Vacation</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-xl leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
||||
{/* Resource */}
|
||||
{!initialResourceId && (
|
||||
<div>
|
||||
<label htmlFor="vac-resource" className={labelClass}>
|
||||
Resource <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="vac-resource"
|
||||
value={resourceId}
|
||||
onChange={(e) => setResourceId(e.target.value)}
|
||||
className={inputClass}
|
||||
required
|
||||
>
|
||||
<option value="">Select a resource…</option>
|
||||
{resourceList.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.displayName} ({r.eid})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label htmlFor="vac-type" className={labelClass}>
|
||||
Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="vac-type"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as VacationType)}
|
||||
className={inputClass}
|
||||
>
|
||||
{VACATION_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{VACATION_TYPE_LABELS[t]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="vac-start" className={labelClass}>
|
||||
Start Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<DateInput
|
||||
id="vac-start"
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
className={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="vac-end" className={labelClass}>
|
||||
End Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<DateInput
|
||||
id="vac-end"
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate}
|
||||
className={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Half-day toggle */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isHalfDay}
|
||||
onChange={(e) => setIsHalfDay(e.target.checked)}
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Half day</span>
|
||||
</label>
|
||||
{isHalfDay && (
|
||||
<div className="flex gap-3">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="radio"
|
||||
value="MORNING"
|
||||
checked={halfDayPart === "MORNING"}
|
||||
onChange={() => setHalfDayPart("MORNING")}
|
||||
className="text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Morning
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="radio"
|
||||
value="AFTERNOON"
|
||||
checked={halfDayPart === "AFTERNOON"}
|
||||
onChange={() => setHalfDayPart("AFTERNOON")}
|
||||
className="text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Afternoon
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conflict warning */}
|
||||
{existingVacations && existingVacations.length > 0 && (
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
|
||||
<strong>Existing vacation in this period:</strong>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{existingVacations.map((v) => (
|
||||
<li key={v.id}>
|
||||
{VACATION_TYPE_LABELS[v.type as VacationType]} —{" "}
|
||||
{new Date(v.startDate).toLocaleDateString("en-GB")} to{" "}
|
||||
{new Date(v.endDate).toLocaleDateString("en-GB")} ({v.status})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team overlap warning */}
|
||||
{teamOverlap && teamOverlap.length > 0 && (
|
||||
<div className="rounded-lg bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-700">
|
||||
<strong>Team members off in this period ({teamOverlap.length}):</strong>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{teamOverlap.map((v) => {
|
||||
const r = v.resource as { displayName: string; eid: string } | null;
|
||||
return (
|
||||
<li key={v.id}>
|
||||
{r?.displayName ?? "—"}{" "}
|
||||
<span className="text-blue-500">({new Date(v.startDate).toLocaleDateString("en-GB")} – {new Date(v.endDate).toLocaleDateString("en-GB")})</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note */}
|
||||
<div>
|
||||
<label htmlFor="vac-note" className={labelClass}>
|
||||
Note (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="vac-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
className={`${inputClass} resize-none`}
|
||||
placeholder="Optional reason or description…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Server error */}
|
||||
{serverError && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
|
||||
{serverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Submitting…" : "Submit Request"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user