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
@@ -11,6 +11,8 @@ import { AiSummaryCard } from "./AiSummaryCard.js";
import { SkillMatrixUpload } from "./SkillMatrixUpload.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ProgressRing } from "~/components/ui/ProgressRing.js";
import { FadeIn } from "~/components/ui/FadeIn.js";
interface ResourceDetailProps {
resourceId: string;
@@ -47,11 +49,19 @@ const allocationStatusColor: Record<string, string> = {
CANCELLED: "bg-red-100 text-red-500",
};
function StatCard({ label, value, sub, tooltip }: { label: string; value: string | number; sub?: string; tooltip?: string }) {
function StatCard({ label, value, sub, tooltip, ring }: { label: string; value: string | number; sub?: string; tooltip?: string; ring?: { value: number; color: string } }) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="text-xs text-gray-500 mb-1 flex items-center">{label}{tooltip && <InfoTooltip content={tooltip} />}</div>
<div className="text-xl font-bold text-gray-900">{value}</div>
{ring ? (
<div className="flex items-center gap-3">
<ProgressRing value={ring.value} size={48} strokeWidth={3.5} color={ring.color}>
<span className="text-sm font-bold text-gray-900 dark:text-gray-100">{value}</span>
</ProgressRing>
</div>
) : (
<div className="text-xl font-bold text-gray-900 dark:text-gray-100">{value}</div>
)}
{sub && <div className="text-xs text-gray-400 mt-0.5">{sub}</div>}
</div>
);
@@ -291,6 +301,10 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
label="Chargeability Target"
value={`${resource.chargeabilityTarget}%`}
tooltip="The percentage of working time this resource is expected to spend on chargeable/billable work."
ring={{
value: resource.chargeabilityTarget,
color: "var(--color-blue-500, #3b82f6)",
}}
/>
{canViewCosts && (
<StatCard
@@ -302,6 +316,16 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
? "Incl. proposed + imported TBD planning"
: "Confirmed + active only"
}
{...(chargeStats != null ? {
ring: {
value: chargeStats.actualChargeability,
color: chargeStats.actualChargeability >= resource.chargeabilityTarget
? "var(--color-green-500, #22c55e)"
: chargeStats.actualChargeability >= resource.chargeabilityTarget - 10
? "var(--color-amber-500, #f59e0b)"
: "var(--color-red-500, #ef4444)",
},
} : {})}
/>
)}
{canViewCosts && (
@@ -418,7 +442,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
)}
{/* Skill Radar Chart */}
<SkillRadarChart skills={skills} />
<FadeIn delay={0.1} direction="up">
<SkillRadarChart skills={skills} />
</FadeIn>
{/* Roles */}
{resourceRoles.length > 0 && (