Files
CapaKraken/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx
T
Hartmut a97597093f 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>
2026-03-19 00:58:06 +01:00

136 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { formatMoney } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { AnimatedNumber } from "~/components/ui/AnimatedNumber.js";
import { ProgressRing } from "~/components/ui/ProgressRing.js";
import { FadeIn } from "~/components/ui/FadeIn.js";
const ACCENT_COLORS = {
green: "var(--color-green-500, #22c55e)",
amber: "var(--color-amber-500, #f59e0b)",
red: "var(--color-red-500, #ef4444)",
} as const;
function StatCard({
label,
value,
suffix,
sub,
info,
accentColor,
delay = 0,
ring,
}: {
label: string;
value: number;
suffix?: string;
sub?: string;
info?: React.ReactNode;
accentColor?: "green" | "amber" | "red";
delay?: number;
ring?: { value: number; color: string };
}) {
const accentBorder = accentColor === "red"
? "border-l-red-500"
: accentColor === "amber"
? "border-l-amber-500"
: accentColor === "green"
? "border-l-green-500"
: "";
return (
<FadeIn delay={delay} direction="up">
<div
className={`rounded-2xl border border-gray-200 bg-white/80 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/70 ${
accentColor ? `border-l-[3px] ${accentBorder}` : ""
}`}
>
<span className="flex items-center text-xs font-semibold uppercase tracking-[0.18em] text-gray-500">
{label}
{info && <InfoTooltip content={info} />}
</span>
{ring ? (
<div className="mt-2 flex items-center gap-3">
<ProgressRing value={ring.value} size={56} strokeWidth={4} color={ring.color}>
<AnimatedNumber value={value} suffix={suffix} className="text-lg font-semibold text-gray-900 dark:text-gray-50" />
</ProgressRing>
</div>
) : (
<span className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-50">
<AnimatedNumber value={value} suffix={suffix} />
</span>
)}
{sub && <span className="mt-1 text-xs text-gray-500 dark:text-gray-400">{sub}</span>}
</div>
</FadeIn>
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
const { data, isLoading } = trpc.dashboard.getOverview.useQuery(undefined, {
staleTime: 60_000,
placeholderData: (prev) => prev,
});
if (isLoading || !data) {
return (
<div className="grid grid-cols-2 gap-3 h-full">
{[...Array(4)].map((_, i) => (
<div
key={i}
className="rounded-2xl border border-gray-200 bg-gray-100 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div className="h-3 w-20 shimmer-skeleton rounded" />
<div className="h-7 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-2 w-24 shimmer-skeleton rounded" />
</div>
))}
</div>
);
}
const budgetPct = data.budgetSummary.avgUtilizationPercent;
const budgetAccent: "red" | "amber" | "green" =
budgetPct > 90 ? "red" : budgetPct >= 70 ? "amber" : "green";
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 h-full content-start">
<StatCard
label="Total Resources"
value={data.totalResources}
sub={`${data.activeResources} active`}
info="All resources in the system. Sub-line shows active resources only."
delay={0}
/>
<StatCard
label="Active Projects"
value={data.activeProjects}
sub={`${data.totalProjects} total`}
info="Projects with status ACTIVE. Total includes all statuses (DRAFT, ON_HOLD, COMPLETED, CANCELLED)."
delay={0.05}
/>
<StatCard
label="Total Allocations"
value={data.totalAllocations}
sub={`${data.activeAllocations} not cancelled`}
info="All allocation records ever created. 'Not cancelled' excludes allocations with status CANCELLED."
delay={0.1}
/>
<StatCard
label="Budget Utilization"
value={budgetPct}
suffix="%"
sub={`${formatMoney(data.budgetSummary.totalCostCents)} of ${formatMoney(data.budgetSummary.totalBudgetCents)}`}
info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost = resource LCR × booked hours."
accentColor={budgetAccent}
delay={0.15}
ring={{ value: budgetPct, color: ACCENT_COLORS[budgetAccent] }}
/>
</div>
);
}