Files
CapaKraken/apps/web/src/components/dashboard/widgets/BudgetForecastWidget.tsx
T

282 lines
12 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
function colorClass(pct: number): string {
if (pct > 90) return "bg-red-500";
if (pct > 70) return "bg-amber-400";
return "bg-green-500";
}
function textColorClass(pct: number): string {
if (pct > 90) return "text-red-700";
if (pct > 70) return "text-amber-700";
return "text-green-700";
}
type BudgetForecastLocation = {
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
activeAssignmentCount?: number;
burnRateCents?: number;
};
type BudgetForecastRow = {
projectId?: string;
projectName: string;
shortCode: string;
clientId: string | null;
clientName: string | null;
budgetCents: number;
spentCents: number;
remainingCents?: number;
burnRate: number;
estimatedExhaustionDate: string | null;
pctUsed: number;
activeAssignmentCount?: number;
calendarLocations?: BudgetForecastLocation[];
};
function formatCurrency(cents: number | undefined): string {
if (cents === undefined) return "—";
return `${(cents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} €`;
}
function formatLocation(location: BudgetForecastLocation): string {
const parts = [
location.countryCode ?? location.countryName ?? null,
location.federalState ?? null,
location.metroCityName ?? null,
].filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
}
function SummaryCard({
label,
value,
helper,
}: {
label: string;
value: string;
helper: string;
}) {
return (
<div className="rounded-2xl border border-gray-200 bg-gray-50/80 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/40">
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
{label}
</div>
<div className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{value}</div>
<div className="mt-0.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">{helper}</div>
</div>
);
}
export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
const showDetails = config.showDetails === true;
const { clients } = useWidgetFilterOptions();
const filters = useMemo<WidgetFilter[]>(
() => [
{ type: "search", key: "search", placeholder: "Search project..." },
{ type: "select", key: "clientId", label: "Client", options: clients },
],
[clients],
);
const { data, isLoading } = trpc.dashboard.getBudgetForecast.useQuery(
undefined,
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
const search = ((config.search as string) ?? "").toLowerCase();
const clientId = (config.clientId as string) ?? "";
const rows = useMemo(() => {
const all = (data ?? []) as BudgetForecastRow[];
return all.filter((r) => {
if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false;
if (clientId && r.clientId !== clientId) return false;
return true;
});
}, [data, search, clientId]);
const totals = useMemo(() => rows.reduce((acc, row) => {
acc.budgetCents += row.budgetCents;
acc.spentCents += row.spentCents;
acc.remainingCents += row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents);
acc.burnRate += row.burnRate;
acc.activeAssignmentCount += row.activeAssignmentCount ?? 0;
return acc;
}, {
budgetCents: 0,
spentCents: 0,
remainingCents: 0,
burnRate: 0,
activeAssignmentCount: 0,
}), [rows]);
if (isLoading && !data) {
return (
<div className="flex flex-col gap-1 pt-1">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2">
<div className="h-3 w-16 shimmer-skeleton rounded" />
<div className="h-3 flex-1 shimmer-skeleton rounded" />
<div className="h-3 w-12 shimmer-skeleton rounded" />
</div>
))}
</div>
);
}
if (rows.length === 0) {
return (
<div className="flex flex-col h-full">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="flex items-center justify-center flex-1 text-sm text-gray-400">
No active projects with budgets.
</div>
</div>
);
}
return (
<div className="flex flex-col h-full overflow-hidden">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="mb-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<SummaryCard
label="Projects"
value={String(rows.length)}
helper={`${totals.activeAssignmentCount} active assignments in scope`}
/>
<SummaryCard
label="Budget"
value={formatCurrency(totals.budgetCents)}
helper={`${formatCurrency(totals.spentCents)} spent`}
/>
<SummaryCard
label="Remaining"
value={formatCurrency(totals.remainingCents)}
helper={`${rows.filter((row) => row.remainingCents !== undefined && row.remainingCents <= 0).length} exhausted`}
/>
<SummaryCard
label="Burn / Month"
value={formatCurrency(totals.burnRate)}
helper="Holiday- and absence-adjusted active burn"
/>
</div>
<div className="overflow-auto flex-1">
<table className="w-full text-xs">
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
Project <InfoTooltip content="Active projects with a defined budget" />
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
Budget Usage <InfoTooltip content="Percentage of total budget consumed by current allocations" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Burn/mo <InfoTooltip content="Current-month burn rate based on active allocations, adjusted for regional holidays and approved absences where resource calendars are available." />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Exhaustion <InfoTooltip content="Projected date when budget will be fully consumed at the current burn rate" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[260px] align-top">
<div>
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
</div>
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
{row.clientName ?? "No client"}
{!showDetails && row.calendarLocations && row.calendarLocations.length > 0
? ` · ${formatLocation(row.calendarLocations[0]!)}`
: ""}
</div>
{showDetails ? (
<div className="mt-1 space-y-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2">
<div>{row.activeAssignmentCount ?? 0} active assignments</div>
<div>Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))}</div>
</div>
<div className="flex flex-wrap gap-1">
{row.calendarLocations && row.calendarLocations.length > 0 ? (
row.calendarLocations.slice(0, 4).map((location) => (
<span
key={`${location.countryCode ?? location.countryName ?? "na"}:${location.federalState ?? "na"}:${location.metroCityName ?? "na"}`}
className="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 dark:border-gray-700 dark:bg-gray-900/70"
>
{formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)}
</span>
))
) : (
<span>No active calendar basis in the current month</span>
)}
</div>
</div>
) : null}
</td>
<td className="px-3 py-2 align-top">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${colorClass(row.pctUsed)}`}
style={{ width: `${Math.min(row.pctUsed, 100)}%` }}
/>
</div>
<span className={`text-[11px] font-semibold tabular-nums w-10 text-right ${textColorClass(row.pctUsed)}`}>
{row.pctUsed}%
</span>
</div>
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
{formatCurrency(row.spentCents)} / {formatCurrency(row.budgetCents)}
</div>
{showDetails ? (
<div className="text-[10px] leading-4 text-gray-500 dark:text-gray-400">
Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))}
</div>
) : null}
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums align-top">
<div>
{row.burnRate > 0 ? formatCurrency(row.burnRate) : "\u2014"}
</div>
{showDetails ? (
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
<div>{row.activeAssignmentCount ?? 0} active assignments</div>
{(row.calendarLocations ?? []).slice(0, 3).map((location) => (
<div key={`${location.countryCode ?? location.countryName ?? "na"}:${location.federalState ?? "na"}:${location.metroCityName ?? "na"}`}>
{formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)}
</div>
))}
</div>
) : null}
</td>
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400 tabular-nums align-top">
<div>{row.estimatedExhaustionDate ?? "\u2014"}</div>
{showDetails ? (
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
at {formatCurrency(row.burnRate)} / month
</div>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}