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:
@@ -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">×</button>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user