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:
@@ -22,7 +22,10 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -31,12 +34,19 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
{/* 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
|
||||
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
|
||||
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" />
|
||||
@@ -56,17 +66,24 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
totalCostCents: number;
|
||||
totalPersonDays: number;
|
||||
}
|
||||
const list = ((projects as unknown as { projects: ProjectRow[] } | undefined)?.projects ?? []) as ProjectRow[];
|
||||
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;
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -79,48 +96,106 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
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"
|
||||
className="app-input min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
<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"
|
||||
className="app-select text-xs"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{Object.values(ProjectStatus).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-auto flex-1">
|
||||
<div className="app-data-table flex-1 overflow-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<thead className="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">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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."
|
||||
@@ -130,35 +205,57 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
</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">
|
||||
<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>
|
||||
<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">
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{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>
|
||||
<tr key={p.id} className="transition hover:bg-gray-50 dark:hover:bg-gray-800/60">
|
||||
<td className="px-3 py-2 font-mono text-gray-600 dark:text-gray-300">
|
||||
{p.shortCode}
|
||||
</td>
|
||||
<td className="px-3 py-2 max-w-[180px] truncate font-medium text-gray-900 dark:text-gray-100">
|
||||
{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] ?? ""}`}>
|
||||
<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">
|
||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
|
||||
{(p.totalCostCents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} €
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{p.totalPersonDays}d</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
|
||||
{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 className="py-8 text-center text-sm text-gray-400">No projects found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user