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
@@ -69,8 +69,8 @@ function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: nu
else if (utilizationPercent > 70) barColor = "bg-yellow-500";
return (
<div className="space-y-0.5 min-w-[80px]">
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden w-full">
<div className="min-w-[104px] space-y-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200/80 dark:bg-gray-700/80">
<div className={clsx("h-full rounded-full transition-all", barColor)} style={{ width: `${cappedPercent}%` }} />
</div>
<div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div>
@@ -104,7 +104,7 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
type="button"
onClick={(e) => { e.stopPropagation(); isOpen ? onClose() : onOpen(); }}
className={clsx(
"inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full cursor-pointer transition-opacity hover:opacity-80",
"inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition",
STATUS_COLORS[project.status] ?? "bg-gray-100 text-gray-700",
)}
title="Click to change status"
@@ -115,7 +115,7 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
</svg>
</button>
{isOpen && (
<div className="absolute left-0 top-full mt-1 z-20 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-[130px]">
<div className="absolute left-0 top-full z-20 mt-2 min-w-[160px] rounded-2xl border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-900">
{ALL_STATUSES.map((s) => (
<button
key={s.value}
@@ -123,10 +123,10 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
disabled={s.value === project.status || updateStatus.isPending}
onClick={(e) => { e.stopPropagation(); updateStatus.mutate({ id: project.id, status: s.value as never }); }}
className={clsx(
"w-full text-left px-3 py-1.5 text-xs transition-colors",
"w-full rounded-xl px-3 py-2 text-left text-xs transition",
s.value === project.status
? "font-semibold text-gray-400 cursor-default"
: "text-gray-700 hover:bg-gray-50 cursor-pointer",
? "cursor-default font-semibold text-gray-400"
: "cursor-pointer text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
)}
>
<span className={clsx("inline-block px-1.5 py-0.5 rounded-full", STATUS_COLORS[s.value] ?? "bg-gray-100 text-gray-700")}>
@@ -226,19 +226,30 @@ export function ProjectsClient() {
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = trpc.project.listWithCosts.useInfiniteQuery(
// Keep this boundary shallow; full TRPC inference here can trip TS depth limits.
} = (trpc.project.listWithCosts.useInfiniteQuery as any)(
{
search: search || undefined,
status: (statusFilter as ProjectStatus) || undefined,
limit: 50,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
getNextPageParam: (lastPage: { nextCursor?: string | null }) => lastPage.nextCursor ?? undefined,
initialCursor: undefined,
placeholderData: (prev) => prev,
placeholderData: (prev: { pages: { projects: ProjectRow[]; nextCursor?: string | null }[] } | undefined) => prev,
staleTime: 15_000,
},
);
) as {
data:
| {
pages: { projects: ProjectRow[]; nextCursor?: string | null }[];
}
| undefined;
isLoading: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => Promise<unknown>;
hasNextPage: boolean | undefined;
};
const allProjects = useMemo(
() => (data?.pages.flatMap((p) => p.projects) ?? []) as unknown as ProjectRow[],
@@ -297,16 +308,16 @@ export function ProjectsClient() {
if (col.isCustom) {
const fieldKey = col.key.replace(/^custom_/, "");
const val = dynFields[fieldKey];
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600">{val != null ? String(val) : "—"}</td>;
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{val != null ? String(val) : "—"}</td>;
}
switch (col.key) {
case "shortCode":
return <td key={col.key} className="px-4 py-3 text-sm font-mono font-medium text-gray-900">{project.shortCode}</td>;
return <td key={col.key} className="px-4 py-3 text-sm font-mono font-medium text-gray-900 dark:text-gray-100">{project.shortCode}</td>;
case "name":
return (
<td key={col.key} className="px-4 py-3 text-sm font-medium text-gray-900 max-w-xs truncate">
<Link href={`/projects/${project.id}`} className="hover:text-brand-600 hover:underline">
<td key={col.key} className="max-w-xs truncate px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
<Link href={`/projects/${project.id}`} className="transition hover:text-brand-600 hover:underline">
{project.name}
</Link>
</td>
@@ -332,14 +343,14 @@ export function ProjectsClient() {
);
case "dates":
return (
<td key={col.key} className="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">
<td key={col.key} className="whitespace-nowrap px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{formatDate(project.startDate)} {formatDate(project.endDate)}
</td>
);
case "budget":
return (
<td key={col.key} className="px-4 py-3 min-w-[120px]">
<div className="text-sm text-gray-900 mb-0.5">
<div className="mb-0.5 text-sm text-gray-900 dark:text-gray-100">
{(project.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })}
</div>
<BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} />
@@ -347,14 +358,14 @@ export function ProjectsClient() {
);
case "allocations":
return (
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 text-right">
<td key={col.key} className="px-4 py-3 text-right text-sm text-gray-600 dark:text-gray-300">
{project.totalPersonDays > 0 ? `${project.totalPersonDays}d` : "—"}
</td>
);
case "responsible":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500"></td>;
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"></td>;
default:
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600"></td>;
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300"></td>;
}
}
@@ -374,20 +385,19 @@ export function ProjectsClient() {
);
}
return (
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
{col.label}
</th>
);
}
return (
<>
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div className="app-page space-y-5">
<div className="app-page-header gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Projects</h1>
<h1 className="app-page-title">Projects</h1>
{!isLoading && (
<p className="text-gray-500 text-sm mt-1">
<p className="app-page-subtitle mt-1">
{projects.length} project{projects.length !== 1 ? "s" : ""}
{hasNextPage ? "+" : ""}
</p>
@@ -397,7 +407,7 @@ export function ProjectsClient() {
<button
type="button"
onClick={() => setWizardOpen(true)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors flex items-center gap-2"
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-brand-700"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
@@ -407,7 +417,7 @@ export function ProjectsClient() {
<button
type="button"
onClick={openNewModal}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium transition-colors flex items-center gap-2"
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
<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" />
@@ -417,19 +427,18 @@ export function ProjectsClient() {
</div>
</div>
{/* Filters */}
<FilterBar>
<input
type="search"
placeholder="Search projects..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm"
className="app-input max-w-xs"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(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"
className="app-select"
>
<option value="">All Statuses</option>
{ALL_STATUSES.map((s) => (
@@ -439,7 +448,7 @@ export function ProjectsClient() {
<select
value={orderTypeFilter}
onChange={(e) => setOrderTypeFilter(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"
className="app-select"
>
<option value="">All Types</option>
{ALL_ORDER_TYPES.map((t) => (
@@ -456,7 +465,7 @@ export function ProjectsClient() {
<button
type="button"
onClick={resetOrder}
className="text-xs text-gray-500 hover:text-gray-700 underline whitespace-nowrap"
className="whitespace-nowrap text-xs text-gray-500 underline transition hover:text-gray-700 dark:hover:text-gray-200"
title="Clear manual row order"
>
Reset order
@@ -464,22 +473,20 @@ export function ProjectsClient() {
)}
</FilterBar>
{/* Filter chips */}
{chips.length > 0 && (
<div className="mb-3">
<FilterChips chips={chips} onClearAll={clearAll} />
</div>
)}
{/* Table */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="app-data-table">
{isLoading ? (
<div className="py-16 text-center text-sm text-gray-400 animate-pulse">Loading projects</div>
<div className="py-16 text-center text-sm text-gray-500 animate-pulse">Loading projects</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<thead className="border-b border-gray-200 dark:border-gray-700">
<tr>
{/* Drag handle column */}
<th className="w-8 px-2" />
@@ -491,7 +498,7 @@ export function ProjectsClient() {
if (el) el.indeterminate = selection.isIndeterminate(projectIds);
}}
onChange={() => selection.toggleAll(projectIds)}
className="rounded border-gray-300"
className="rounded border-gray-300 dark:border-gray-600"
/>
</th>
{visibleColumns.map(renderHeader)}
@@ -500,7 +507,7 @@ export function ProjectsClient() {
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{projects.map((project) => {
const isSelected = selection.selectedIds.has(project.id);
return (
@@ -509,14 +516,14 @@ export function ProjectsClient() {
id={project.id}
dragRef={rowDragRef}
onDrop={(draggedId) => reorder(draggedId, project.id)}
className={`hover:bg-gray-50 transition-colors ${isSelected ? "bg-brand-50" : ""}`}
className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}
>
<td className="px-4 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => selection.toggle(project.id)}
className="rounded border-gray-300"
className="rounded border-gray-300 dark:border-gray-600"
/>
</td>
{visibleColumns.map((col) => renderCell(col, project))}
@@ -525,11 +532,11 @@ export function ProjectsClient() {
<button
type="button"
onClick={() => openEditModal(project as unknown as Project)}
className="text-xs text-gray-600 hover:text-gray-900 hover:underline font-medium transition-colors"
className="text-xs font-medium text-gray-600 transition-colors hover:text-gray-900 hover:underline dark:text-gray-300 dark:hover:text-gray-100"
>
Edit
</button>
<Link href={`/projects/${project.id}`} className="text-xs text-blue-600 hover:text-blue-800 hover:underline font-medium">
<Link href={`/projects/${project.id}`} className="text-xs font-medium text-blue-600 hover:text-blue-800 hover:underline dark:text-blue-300 dark:hover:text-blue-200">
View
</Link>
</div>
@@ -542,7 +549,7 @@ export function ProjectsClient() {
</div>
{projects.length === 0 && (
<div className="text-center py-12 text-gray-500">
<div className="py-14 text-center text-sm text-gray-500">
No projects found.{" "}
<button type="button" onClick={openNewModal} className="text-brand-600 hover:underline font-medium">
Create your first project.
@@ -561,8 +568,8 @@ export function ProjectsClient() {
{/* 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 rounded-xl shadow-2xl p-5 min-w-[220px]" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Set status for {selection.count} projects</h3>
<div className="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Set status for {selection.count} projects</h3>
<div className="flex flex-col gap-1">
{ALL_STATUSES.map((s) => (
<button
@@ -572,7 +579,7 @@ export function ProjectsClient() {
setConfirmBatchStatus({ ids: selection.selectedArray, status: s.value });
setBatchStatusPicker(false);
}}
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-gray-50 transition-colors"
className="w-full rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span className={clsx("inline-block px-2 py-0.5 text-xs rounded-full", STATUS_COLORS[s.value])}>
{s.label}
@@ -614,6 +621,6 @@ export function ProjectsClient() {
{/* Wizard */}
<ProjectWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
</>
</div>
);
}
+1 -5
View File
@@ -1,9 +1,5 @@
import { ProjectsClient } from "./ProjectsClient.js";
export default function ProjectsPage() {
return (
<div className="p-6">
<ProjectsClient />
</div>
);
return <ProjectsClient />;
}