a97597093f
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>
136 lines
4.5 KiB
TypeScript
136 lines
4.5 KiB
TypeScript
"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>
|
||
);
|
||
}
|