feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish

Dashboard: expanded chargeability widget, resource/project table widgets
with sorting and filters, stat cards with formatMoney integration.

Chargeability: new report client with filtering, chargeability-bookings
use case, updated dashboard overview logic.

Dispo import: TBD project handling, parse-dispo-matrix improvements,
stage-dispo-projects resource value scores, new tests.

Estimates: CommercialTermsEditor component, commercial-terms engine
module, expanded estimate schemas and types.

UI: AppShell navigation updates, timeline filter/toolbar enhancements,
role management improvements, signin page redesign, Tailwind/globals
polish, SystemSettings SMTP section, anonymization support.

Tests: new router tests (anonymization, chargeability, effort-rule,
entitlement, estimate, experience-multiplier, notification, resource,
staffing, vacation).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-14 23:29:07 +01:00
parent ad0855902b
commit 625a842d89
74 changed files with 11680 additions and 1583 deletions
+59 -19
View File
@@ -6,8 +6,16 @@ import { trpc } from "~/lib/trpc/client.js";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
const PRESET_COLORS = [
"#6366f1", "#8b5cf6", "#ec4899", "#ef4444", "#f97316",
"#eab308", "#22c55e", "#14b8a6", "#06b6d4", "#3b82f6",
"#6366f1",
"#8b5cf6",
"#ec4899",
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#14b8a6",
"#06b6d4",
"#3b82f6",
];
interface RoleModalProps {
@@ -69,33 +77,48 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
}
}
const inputClass = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
const labelClass = "block text-sm font-medium text-gray-700 mb-1";
const inputClass = "app-input";
const labelClass = "app-label";
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/55 py-8 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
className="mx-4 w-full max-w-md rounded-3xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900"
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isEditing ? "Edit Role" : "New Role"}
</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
<button
type="button"
onClick={onClose}
className="text-xl leading-none text-gray-400 transition hover:text-gray-600 dark:hover:text-gray-200"
>
&times;
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
<div>
<label className={labelClass}>Name <span className="text-red-500">*</span></label>
<label className={labelClass}>
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => { setName(e.target.value); setServerError(null); }}
onChange={(e) => {
setName(e.target.value);
setServerError(null);
}}
placeholder="e.g. 3D Artist"
className={inputClass}
maxLength={100}
@@ -117,8 +140,16 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
<div>
<label className={labelClass}>Color</label>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full border-2 border-gray-200 flex-shrink-0" style={{ backgroundColor: color }} />
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/70">
<div className="mb-3 flex items-center gap-3">
<div
className="h-8 w-8 flex-shrink-0 rounded-full border-2 border-gray-200 dark:border-gray-600"
style={{ backgroundColor: color }}
/>
<p className="text-sm text-gray-600 dark:text-gray-300">
Pick a color that stays readable in timelines and chips.
</p>
</div>
<div className="flex flex-wrap gap-2 flex-1">
{PRESET_COLORS.map((c) => (
<button
@@ -137,23 +168,32 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 rounded cursor-pointer border border-gray-300"
className="mt-3 h-8 w-10 cursor-pointer rounded border border-gray-300 bg-transparent dark:border-gray-600"
title="Custom color"
/>
</div>
</div>
{serverError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300">
{serverError}
</div>
)}
<div className="flex items-center justify-end gap-3 pt-2">
<button type="button" onClick={onClose} disabled={isPending} className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 disabled:opacity-50">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="rounded-xl px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-100 hover:text-gray-900 disabled:opacity-50 dark:text-gray-300 dark:hover:bg-gray-800"
>
Cancel
</button>
<button type="submit" disabled={isPending} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50">
<button
type="submit"
disabled={isPending}
className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:opacity-50"
>
{isPending ? "Saving…" : "Save"}
</button>
</div>
+95 -42
View File
@@ -77,37 +77,36 @@ export function RolesClient() {
const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(showInactive ? [{ label: "Inactive included", onRemove: () => setShowInactive(false) }] : []),
...(showInactive
? [{ label: "Inactive included", onRemove: () => setShowInactive(false) }]
: []),
];
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div className="app-page mx-auto max-w-6xl space-y-5">
<div className="app-page-header gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Roles</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage role definitions and resource assignments
</p>
<h1 className="app-page-title">Roles</h1>
<p className="app-page-subtitle mt-1">Manage role definitions and resource assignments</p>
</div>
<button
type="button"
onClick={openCreate}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700"
>
+ New Role
New Role
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 mb-3">
<div className="app-toolbar flex flex-wrap items-center gap-3">
<input
type="text"
placeholder="Search roles…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64"
className="app-input w-64"
/>
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<label className="flex cursor-pointer items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
<input
type="checkbox"
checked={showInactive}
@@ -125,34 +124,75 @@ export function RolesClient() {
)}
{actionError && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 flex items-center justify-between">
<div className="flex items-center justify-between rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300">
{actionError}
<button type="button" onClick={() => setActionError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
<button
type="button"
onClick={() => setActionError(null)}
className="text-red-400 hover:text-red-600 text-lg leading-none"
>
&times;
</button>
</div>
)}
{/* Table */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="app-data-table">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Description</th>
<SortableColumnHeader label="Resources" field="resourceRoles" sortField={sortField} sortDir={sortDir} onSort={(f) => toggle(f, (r) => r._count.resourceRoles)} align="center" tooltip="Number of resources that currently have this role assigned (active assignments only)." />
<SortableColumnHeader label="Allocations" field="allocations" sortField={sortField} sortDir={sortDir} onSort={(f) => toggle(f, (r) => r._count.allocations)} align="center" tooltip="Total number of planning entries that use this role, including open-demand compatibility rows." />
<SortableColumnHeader label="Status" field="isActive" sortField={sortField} sortDir={sortDir} onSort={(f) => toggle(f, (r) => (r.isActive ? 0 : 1))} align="center" tooltip="Active roles are available for assignment. Inactive roles are hidden from pickers but existing assignments remain." />
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
<tr className="border-b border-gray-200 dark:border-gray-700">
<SortableColumnHeader
label="Name"
field="name"
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
/>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
Description
</th>
<SortableColumnHeader
label="Resources"
field="resourceRoles"
sortField={sortField}
sortDir={sortDir}
onSort={(f) => toggle(f, (r) => r._count.resourceRoles)}
align="center"
tooltip="Number of resources that currently have this role assigned (active assignments only)."
/>
<SortableColumnHeader
label="Allocations"
field="allocations"
sortField={sortField}
sortDir={sortDir}
onSort={(f) => toggle(f, (r) => r._count.allocations)}
align="center"
tooltip="Total number of planning entries that use this role, including open-demand compatibility rows."
/>
<SortableColumnHeader
label="Status"
field="isActive"
sortField={sortField}
sortDir={sortDir}
onSort={(f) => toggle(f, (r) => (r.isActive ? 0 : 1))}
align="center"
tooltip="Active roles are available for assignment. Inactive roles are hidden from pickers but existing assignments remain."
/>
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-400">Loading</td>
<td colSpan={6} className="py-12 text-center text-gray-400">
Loading
</td>
</tr>
)}
{!isLoading && sorted.length === 0 && (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-400">
<td colSpan={6} className="py-12 text-center text-gray-400">
No roles found. Create one to get started.
</td>
</tr>
@@ -168,7 +208,9 @@ export function RolesClient() {
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: role.color ?? "#6366f1" }}
/>
<span className="font-medium text-gray-900 dark:text-gray-100">{role.name}</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{role.name}
</span>
</div>
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 max-w-xs truncate">
@@ -185,11 +227,13 @@ export function RolesClient() {
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
role.isActive
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400"
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
}`}>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
role.isActive
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400"
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
}`}
>
{role.isActive ? "Active" : "Inactive"}
</span>
</td>
@@ -230,7 +274,10 @@ export function RolesClient() {
)}
<button
type="button"
onClick={() => { setConfirmDelete(role); setActionError(null); }}
onClick={() => {
setConfirmDelete(role);
setActionError(null);
}}
className="text-xs text-red-500 hover:text-red-700"
>
Delete
@@ -243,7 +290,6 @@ export function RolesClient() {
</table>
</div>
{/* Modals */}
{modalOpen && (
<RoleModal
role={editRole}
@@ -253,26 +299,33 @@ export function RolesClient() {
)}
{confirmDelete && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-sm mx-4 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Delete Role</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 px-4 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-6 shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
Delete Role
</h3>
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Are you sure you want to delete <strong>{confirmDelete.name}</strong>?
{(confirmDelete._count.resourceRoles > 0 || confirmDelete._count.allocations > 0) && (
<span className="block mt-1 text-amber-600">
This role is assigned to {confirmDelete._count.resourceRoles} resource(s) and {confirmDelete._count.allocations} allocation(s). Deletion will be blocked.
This role is assigned to {confirmDelete._count.resourceRoles} resource(s) and{" "}
{confirmDelete._count.allocations} allocation(s). Deletion will be blocked.
</span>
)}
</p>
<div className="flex gap-3 justify-end">
<button type="button" onClick={() => setConfirmDelete(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setConfirmDelete(null)}
className="rounded-xl px-4 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100 hover:text-gray-800 dark:text-gray-300 dark:hover:bg-gray-800"
>
Cancel
</button>
<button
type="button"
onClick={() => deleteMutation.mutate({ id: confirmDelete.id })}
disabled={deleteMutation.isPending}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium disabled:opacity-50"
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
>
{deleteMutation.isPending ? "Deleting…" : "Delete"}
</button>