"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 (
{label}
{value}
{helper}
); } export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) { const showDetails = config.showDetails === true; const { clients } = useWidgetFilterOptions(); const filters = useMemo( () => [ { 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 (
{[...Array(5)].map((_, i) => (
))}
); } if (rows.length === 0) { return (
{})} />
No active projects with budgets.
); } return (
{})} />
row.remainingCents !== undefined && row.remainingCents <= 0).length} exhausted`} />
{rows.map((row) => ( ))}
Project Budget Usage Burn/mo Exhaustion
{row.shortCode} {row.projectName}
{row.clientName ?? "No client"} {!showDetails && row.calendarLocations && row.calendarLocations.length > 0 ? ` · ${formatLocation(row.calendarLocations[0]!)}` : ""}
{showDetails ? (
{row.activeAssignmentCount ?? 0} active assignments
Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))}
{row.calendarLocations && row.calendarLocations.length > 0 ? ( row.calendarLocations.slice(0, 4).map((location) => ( {formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)} )) ) : ( No active calendar basis in the current month )}
) : null}
{row.pctUsed}%
{formatCurrency(row.spentCents)} / {formatCurrency(row.budgetCents)}
{showDetails ? (
Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))}
) : null}
{row.burnRate > 0 ? formatCurrency(row.burnRate) : "\u2014"}
{showDetails ? (
{row.activeAssignmentCount ?? 0} active assignments
{(row.calendarLocations ?? []).slice(0, 3).map((location) => (
{formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)}
))}
) : null}
{row.estimatedExhaustionDate ?? "\u2014"}
{showDetails ? (
at {formatCurrency(row.burnRate)} / month
) : null}
); }