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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user