feat: Sprint 2 — data storytelling and visual richness

Timeline project color system:
- 16-color deterministic palette (same project = same color always)
- Resource panel: allocation blocks colored by project instead of uniform green
- Project panel: colored left border + dot on project headers
- ProjectColorLegend: floating strip showing color-to-project mapping
- Utilization intensity tint: subtle background gradient on resource rows

Table visual enhancements:
- Resources: inline 3px utilization bar below chargeability percentage
- Resources: 32px avatar circles with initials + role-derived colors
- Projects: animated budget bars, styled resource count badges
- Allocations: 3px left border colored by status (green/amber/blue/gray/red)

KPI progress rings:
- Budget utilization: ProgressRing wrapping AnimatedNumber on dashboard
- Chargeability report: ring on average chargeability summary card
- Resource detail: rings on chargeability target + actual metrics
- Vacation balance: ring showing remaining days with color thresholds
- Demand widget: mini rings on FTE fill rate per project
- Resource detail: FadeIn on SkillRadarChart

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 00:58:06 +01:00
parent ae92923c28
commit a97597093f
13 changed files with 399 additions and 53 deletions
@@ -59,7 +59,7 @@ function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: nu
return (
<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 className={clsx("h-full rounded-full transition-all duration-700 ease-out", barColor)} style={{ width: `${cappedPercent}%` }} />
</div>
<div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div>
</div>
@@ -407,8 +407,17 @@ export function ProjectsClient() {
);
case "allocations":
return (
<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 key={col.key} className="px-4 py-3 text-right text-sm">
{project.totalPersonDays > 0 ? (
<span className="inline-flex items-center gap-1 rounded-full bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:bg-brand-900/30 dark:text-brand-300">
<svg className="h-3 w-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
{project.totalPersonDays}d
</span>
) : (
<span className="text-gray-400 dark:text-gray-500"></span>
)}
</td>
);
case "responsible":
@@ -1148,20 +1148,51 @@ export function ResourcesClient() {
{resource.eid}
</td>
);
case "displayName":
case "displayName": {
const initials = resource.displayName
.split(/\s+/)
.map((w) => w[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
const rr =
(
resource as unknown as {
resourceRoles?: {
isPrimary: boolean;
role: { color: string | null };
}[];
}
).resourceRoles ?? [];
const primaryRole = rr.find((r) => r.isPrimary);
const avatarColor =
primaryRole?.role.color ??
`hsl(${[...resource.displayName].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360}, 55%, 45%)`;
return (
<td key={col.key} className="px-4 py-3">
<Link
href={`/resources/${resource.id}`}
className="text-sm font-medium text-gray-900 transition-colors hover:text-brand-600 hover:underline dark:text-gray-100"
className="inline-flex items-center gap-2.5 transition-colors hover:text-brand-600 group"
>
{resource.displayName}
<span
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style={{ backgroundColor: avatarColor }}
>
{initials}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-gray-900 group-hover:text-brand-600 group-hover:underline dark:text-gray-100">
{resource.displayName}
</span>
<span className="block text-xs text-gray-500 dark:text-gray-400">
{resource.email}
</span>
</span>
</Link>
<div className="text-xs text-gray-500 dark:text-gray-400">
{resource.email}
</div>
</td>
);
}
case "chapter":
return (
<td
@@ -1202,8 +1233,20 @@ export function ResourcesClient() {
: actual >= target - 20
? "text-amber-600 dark:text-amber-300"
: "text-red-600 dark:text-red-300";
// Bar color based on % of target achieved
const barRatio = actual != null && target > 0 ? actual / target : 0;
const barColor =
actual == null
? "bg-gray-300 dark:bg-gray-600"
: barRatio >= 0.8
? "bg-green-500"
: barRatio >= 0.5
? "bg-amber-500"
: "bg-red-500";
const barWidth = actual != null ? Math.min(actual, 100) : 0;
const isOverflow = actual != null && actual > 100;
return (
<td key={col.key} className="px-4 py-3 text-sm">
<td key={col.key} className="px-4 py-3 text-sm min-w-[120px]">
<div>
<span className={`font-medium ${color}`}>
{actual != null ? `${actual}%` : "—"}
@@ -1213,7 +1256,22 @@ export function ResourcesClient() {
({expected}% exp.)
</span>
)}
<div className="text-xs text-gray-400">Target: {target}%</div>
{actual !== target && (
<div className="text-xs text-gray-400">Target: {target}%</div>
)}
{actual != null && (
<div className="mt-1 flex items-center gap-1">
<div className="h-[3px] flex-1 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className={`h-full rounded-full transition-all duration-700 ease-out ${barColor}`}
style={{ width: `${barWidth}%` }}
/>
</div>
{isOverflow && (
<span className="text-[9px] font-bold text-green-600 dark:text-green-400" title={`${actual}% actual`}>+</span>
)}
</div>
)}
</div>
</td>
);