"use client"; import { useMemo } from "react"; import Link from "next/link"; import { trpc } from "~/lib/trpc/client.js"; import type { WidgetProps } from "~/components/dashboard/widget-registry.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { ShoringBadge } from "~/components/projects/ShoringIndicator.js"; import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js"; import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js"; import { formatMoney } from "~/lib/format.js"; type ProjectHealthRow = { id: string; projectName: string; shortCode: string; status: string; clientId: string | null; clientName: string | null; budgetHealth: number; staffingHealth: number; timelineHealth: number; compositeScore: number; budgetCents?: number | null; spentCents?: number; remainingBudgetCents?: number | null; budgetUtilizationPercent?: number | null; demandHeadcountTotal?: number; demandHeadcountFilled?: number; demandHeadcountOpen?: number; demandRequirementCount?: number; plannedEndDate?: string | Date | null; daysUntilEndDate?: number | null; timelineStatus?: "ON_TRACK" | "DUE_SOON" | "OVERDUE" | "UNSCHEDULED" | null; calendarLocations?: Array<{ countryCode?: string | null; countryName?: string | null; federalState?: string | null; metroCityName?: string | null; assignmentCount: number; spentCents: number; }>; derivation?: { periodStart: string; periodEnd: string; calendarContextCount: number; holidayAwareAssignmentCount: number; fallbackAssignmentCount: number; baseSpentCents: number; adjustedSpentCents: number; publicHolidayDayEquivalent: number; publicHolidayCostDeductionCents: number; absenceDayEquivalent: number; absenceCostDeductionCents: number; } | null; }; function healthDot(value: number): string { if (value >= 70) return "bg-green-500"; if (value >= 40) return "bg-amber-400"; return "bg-red-500"; } function scoreBadge(score: number): string { if (score >= 70) return "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300"; if (score >= 40) return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"; return "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300"; } function formatShortDate(value?: string | Date | null): string { if (!value) { return "No end date"; } const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) { return "No end date"; } return new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", }).format(date); } function formatTimeline(daysUntilEndDate?: number | null, timelineStatus?: string | null): string { if (timelineStatus === "UNSCHEDULED" || daysUntilEndDate == null) { return "No end date"; } if (daysUntilEndDate < 0) { return `${Math.abs(daysUntilEndDate)} days overdue`; } if (daysUntilEndDate === 0) { return "Due today"; } return `${daysUntilEndDate} days left`; } function formatLocation(location: { countryCode?: string | null; countryName?: string | null; federalState?: string | null; metroCityName?: string | null; }): 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 formatDayEquivalent(value?: number | null): string { if (value == null) return "—"; return Number.isInteger(value) ? String(value) : value.toFixed(1); } export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) { const showDetails = config.showDetails === true; const { clients } = useWidgetFilterOptions({ clients: true }); const filters = useMemo( () => [ { type: "search", key: "search", placeholder: "Search project..." }, { type: "select", key: "clientId", label: "Client", options: clients }, ], [clients], ); const { data, isLoading } = trpc.dashboard.getProjectHealth.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 ProjectHealthRow[]; 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]); if (isLoading && !data) { return (
{[...Array(5)].map((_, i) => (
))}
); } if (rows.length === 0) { return (
{})} />
No active projects found.
); } return (
{})} />
{rows.map((row) => ( ))}
Project B / S / T Shoring Score
{row.shortCode} {row.projectName}
{showDetails ? (
Budget: {formatMoney(row.spentCents ?? 0)} spent {row.budgetCents != null ? ` / ${formatMoney(row.budgetCents)} budget` : " / no budget"} {row.remainingBudgetCents != null ? ` / ${formatMoney(row.remainingBudgetCents)} remaining` : ""}
Staffing: {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} HC {typeof row.demandHeadcountOpen === "number" ? `, ${row.demandHeadcountOpen} open` : ""} {typeof row.demandRequirementCount === "number" ? ` across ${row.demandRequirementCount} demands` : ""}
Timeline: {formatShortDate(row.plannedEndDate)} · {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
{row.derivation ? ( <>
Spend basis: {row.derivation.calendarContextCount} calendar bases · {row.derivation.holidayAwareAssignmentCount} holiday-aware {row.derivation.fallbackAssignmentCount > 0 ? ` · ${row.derivation.fallbackAssignmentCount} fallback` : ""}
Base {formatMoney(row.derivation.baseSpentCents)} {"->"} Effective {formatMoney(row.derivation.adjustedSpentCents)}
Holidays -{formatMoney(row.derivation.publicHolidayCostDeductionCents)} ({formatDayEquivalent(row.derivation.publicHolidayDayEquivalent)}d) {" · "} Absence -{formatMoney(row.derivation.absenceCostDeductionCents)} ({formatDayEquivalent(row.derivation.absenceDayEquivalent)}d)
) : null} {(row.calendarLocations ?? []).length > 0 ? (
Calendar basis: {(row.calendarLocations ?? []) .slice(0, 2) .map((location) => `${formatLocation(location)} (${formatMoney(location.spentCents)} / ${location.assignmentCount} assign.)`) .join(" · ")} {(row.calendarLocations ?? []).length > 2 ? ` · +${(row.calendarLocations ?? []).length - 2} more` : ""}
) : null}
) : null}
B {row.budgetUtilizationPercent ?? 0}% used
{showDetails ? (
S {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} · T {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
) : null}
{row.compositeScore}
); }