feat(platform): checkpoint current implementation state
This commit is contained in:
@@ -42,6 +42,19 @@ type BudgetForecastRow = {
|
||||
pctUsed: number;
|
||||
activeAssignmentCount?: number;
|
||||
calendarLocations?: BudgetForecastLocation[];
|
||||
derivation?: {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
calendarContextCount: number;
|
||||
holidayAwareAssignmentCount: number;
|
||||
fallbackAssignmentCount: number;
|
||||
baseBurnRateCents: number;
|
||||
adjustedBurnRateCents: number;
|
||||
publicHolidayDayEquivalent: number;
|
||||
publicHolidayCostDeductionCents: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceCostDeductionCents: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
function formatCurrency(cents: number | undefined): string {
|
||||
@@ -49,6 +62,11 @@ function formatCurrency(cents: number | undefined): string {
|
||||
return `${(cents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} €`;
|
||||
}
|
||||
|
||||
function formatDayEquivalent(value: number | undefined): string {
|
||||
if (value === undefined) return "—";
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1);
|
||||
}
|
||||
|
||||
function formatLocation(location: BudgetForecastLocation): string {
|
||||
const parts = [
|
||||
location.countryCode ?? location.countryName ?? null,
|
||||
@@ -65,7 +83,7 @@ function SummaryCard({
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
helper: 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">
|
||||
@@ -73,7 +91,9 @@ function SummaryCard({
|
||||
{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>
|
||||
{helper ? (
|
||||
<div className="mt-0.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">{helper}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -113,6 +133,9 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
acc.remainingCents += row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents);
|
||||
acc.burnRate += row.burnRate;
|
||||
acc.activeAssignmentCount += row.activeAssignmentCount ?? 0;
|
||||
acc.baseBurnRateCents += row.derivation?.baseBurnRateCents ?? row.burnRate;
|
||||
acc.publicHolidayCostDeductionCents += row.derivation?.publicHolidayCostDeductionCents ?? 0;
|
||||
acc.absenceCostDeductionCents += row.derivation?.absenceCostDeductionCents ?? 0;
|
||||
return acc;
|
||||
}, {
|
||||
budgetCents: 0,
|
||||
@@ -120,6 +143,9 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
remainingCents: 0,
|
||||
burnRate: 0,
|
||||
activeAssignmentCount: 0,
|
||||
baseBurnRateCents: 0,
|
||||
publicHolidayCostDeductionCents: 0,
|
||||
absenceCostDeductionCents: 0,
|
||||
}), [rows]);
|
||||
|
||||
if (isLoading && !data) {
|
||||
@@ -154,22 +180,26 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
<SummaryCard
|
||||
label="Projects"
|
||||
value={String(rows.length)}
|
||||
helper={`${totals.activeAssignmentCount} active assignments in scope`}
|
||||
{...(showDetails ? { helper: `${totals.activeAssignmentCount} active assignments in scope` } : {})}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Budget"
|
||||
value={formatCurrency(totals.budgetCents)}
|
||||
helper={`${formatCurrency(totals.spentCents)} spent`}
|
||||
{...(showDetails ? { helper: `${formatCurrency(totals.spentCents)} spent` } : {})}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Remaining"
|
||||
value={formatCurrency(totals.remainingCents)}
|
||||
helper={`${rows.filter((row) => row.remainingCents !== undefined && row.remainingCents <= 0).length} exhausted`}
|
||||
{...(showDetails
|
||||
? { 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"
|
||||
{...(showDetails ? {
|
||||
helper: `Base ${formatCurrency(totals.baseBurnRateCents)} · Hol -${formatCurrency(totals.publicHolidayCostDeductionCents)} · Abs -${formatCurrency(totals.absenceCostDeductionCents)}`,
|
||||
} : {})}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-auto flex-1">
|
||||
@@ -200,15 +230,21 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
</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>
|
||||
{row.derivation ? (
|
||||
<>
|
||||
<div>{row.derivation.calendarContextCount} calendar bases</div>
|
||||
<div>
|
||||
{row.derivation.holidayAwareAssignmentCount} holiday-aware
|
||||
{row.derivation.fallbackAssignmentCount > 0 ? ` · ${row.derivation.fallbackAssignmentCount} fallback` : ""}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.calendarLocations && row.calendarLocations.length > 0 ? (
|
||||
@@ -254,7 +290,22 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
</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.derivation ? (
|
||||
<>
|
||||
<div>
|
||||
Base {formatCurrency(row.derivation.baseBurnRateCents)} {"->"} Adj {formatCurrency(row.derivation.adjustedBurnRateCents)}
|
||||
</div>
|
||||
<div>
|
||||
Hol -{formatCurrency(row.derivation.publicHolidayCostDeductionCents)} ({formatDayEquivalent(row.derivation.publicHolidayDayEquivalent)}d) · Abs -{formatCurrency(row.derivation.absenceCostDeductionCents)} ({formatDayEquivalent(row.derivation.absenceDayEquivalent)}d)
|
||||
</div>
|
||||
<div>
|
||||
{row.derivation.holidayAwareAssignmentCount} holiday-aware assignment{row.derivation.holidayAwareAssignmentCount === 1 ? "" : "s"}
|
||||
{row.derivation.fallbackAssignmentCount > 0 ? ` · ${row.derivation.fallbackAssignmentCount} fallback` : ""}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<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)}
|
||||
|
||||
@@ -53,6 +53,17 @@ function formatDemandSource(source: DemandDerivation["demandSource"] | undefined
|
||||
return "No demand basis";
|
||||
}
|
||||
|
||||
function renderCalendarBasis(derivation: DemandDerivation): string {
|
||||
if (derivation.calendarLocations.length === 0) {
|
||||
return "No location-based booking basis";
|
||||
}
|
||||
|
||||
return derivation.calendarLocations
|
||||
.slice(0, 2)
|
||||
.map((location) => `${formatLocation(location)} (${formatHours(location.allocatedHours)})`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const showDetails = config.showDetails === true;
|
||||
const groupBy = (config.groupBy as GroupBy) || "project";
|
||||
@@ -198,21 +209,15 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
row.name
|
||||
)}
|
||||
</div>
|
||||
{showDetails && groupBy === "project" && row.derivation ? (
|
||||
{showDetails && row.derivation ? (
|
||||
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500">
|
||||
<div>
|
||||
{row.derivation.periodStart} to {row.derivation.periodEnd}
|
||||
</div>
|
||||
<div>
|
||||
{row.derivation.calendarLocations.length > 0
|
||||
? row.derivation.calendarLocations
|
||||
.slice(0, 2)
|
||||
.map((location) =>
|
||||
`${formatLocation(location)} (${formatHours(location.allocatedHours)})`,
|
||||
)
|
||||
.join(" · ")
|
||||
: "No location-based booking basis"}
|
||||
</div>
|
||||
<div>{renderCalendarBasis(row.derivation)}</div>
|
||||
{groupBy !== "project" ? (
|
||||
<div>{formatHours(row.derivation.periodWorkingHoursBase)} per 1.0 FTE base</div>
|
||||
) : null}
|
||||
{row.derivation.calendarLocations.length > 2 ? (
|
||||
<div>+ {row.derivation.calendarLocations.length - 2} more calendar contexts</div>
|
||||
) : null}
|
||||
@@ -221,7 +226,7 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right align-top">
|
||||
<div className="text-gray-700">{row.allocatedHours}h</div>
|
||||
{showDetails && groupBy === "project" && row.derivation ? (
|
||||
{showDetails && row.derivation ? (
|
||||
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500">
|
||||
<div>{row.derivation.calendarLocations.length} calendar basis{row.derivation.calendarLocations.length === 1 ? "" : "es"}</div>
|
||||
<div>{row.resourceCount} resource{row.resourceCount === 1 ? "" : "s"} in scope</div>
|
||||
@@ -262,7 +267,7 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
)}
|
||||
<td className="px-3 py-2 text-right align-top text-gray-500">
|
||||
<div>{row.resourceCount}</div>
|
||||
{showDetails && groupBy === "project" && row.derivation?.calendarLocations.length ? (
|
||||
{showDetails && row.derivation?.calendarLocations.length ? (
|
||||
<div className="mt-1 text-[10px] leading-4 text-gray-500">
|
||||
{row.derivation.calendarLocations.reduce((sum, location) => sum + location.resourceCount, 0)} resource entries across locations
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,23 @@ type PeakTimesChartRow = {
|
||||
label: string;
|
||||
bookedHours: number;
|
||||
capacityHours: number;
|
||||
baseAvailableHours: number;
|
||||
holidayHoursDeduction: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceHoursDeduction: number;
|
||||
utilizationPct: number;
|
||||
remainingHours: number;
|
||||
overbookedHours: number;
|
||||
isCurrentPeriod: boolean;
|
||||
calendarContextCount: number;
|
||||
calendarLocations: Array<{
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
resourceCount: number;
|
||||
effectiveAvailableHours: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
interface PeakTimesChartProps {
|
||||
@@ -26,6 +39,16 @@ function formatHours(value: number): string {
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatDayEquivalent(value: number): string {
|
||||
return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1);
|
||||
}
|
||||
|
||||
function formatLocation(input: PeakTimesChartRow["calendarLocations"][number]): string {
|
||||
const parts = [input.countryCode ?? input.countryName, input.federalState, input.metroCityName]
|
||||
.filter((part): part is string => Boolean(part));
|
||||
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
|
||||
}
|
||||
|
||||
function utilizationBarTone(utilizationPct: number): string {
|
||||
if (utilizationPct > 100) return "bg-red-500";
|
||||
if (utilizationPct > 75) return "bg-emerald-500";
|
||||
@@ -132,7 +155,19 @@ export default function PeakTimesChart({
|
||||
key={row.period}
|
||||
type="button"
|
||||
className="group flex h-full min-w-0 flex-col items-center rounded-2xl px-1 text-left transition-colors"
|
||||
title={`${row.label}: ${row.utilizationPct}% utilization, ${formatHours(row.bookedHours)}h booked, ${formatHours(row.capacityHours)}h capacity, ${formatHours(row.remainingHours)}h free, ${formatHours(row.overbookedHours)}h overbooked`}
|
||||
title={[
|
||||
`${row.label}: ${row.utilizationPct}% utilization`,
|
||||
`${formatHours(row.bookedHours)}h booked`,
|
||||
`${formatHours(row.capacityHours)}h effective capacity`,
|
||||
`${formatHours(row.baseAvailableHours)}h base`,
|
||||
`${formatHours(row.holidayHoursDeduction)}h holidays`,
|
||||
`${formatHours(row.absenceHoursDeduction)}h absences (${formatDayEquivalent(row.absenceDayEquivalent)}d)`,
|
||||
`${row.calendarContextCount} calendar base${row.calendarContextCount === 1 ? "" : "s"}`,
|
||||
...row.calendarLocations.slice(0, 3).map((location) =>
|
||||
`${formatLocation(location)}: ${location.resourceCount}x, ${formatHours(location.effectiveAvailableHours)}h capacity`),
|
||||
`${formatHours(row.remainingHours)}h free`,
|
||||
`${formatHours(row.overbookedHours)}h overbooked`,
|
||||
].join(", ")}
|
||||
onMouseEnter={() => setHoveredPeriod(row.period)}
|
||||
onMouseLeave={() => setHoveredPeriod((current) => (current === row.period ? null : current))}
|
||||
onClick={() => onSelectedPeriodChange?.(row.period)}
|
||||
|
||||
@@ -10,6 +10,51 @@ import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/Widge
|
||||
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";
|
||||
@@ -69,6 +114,11 @@ function formatLocation(location: {
|
||||
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 });
|
||||
@@ -90,7 +140,7 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const clientId = (config.clientId as string) ?? "";
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const all = data ?? [];
|
||||
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;
|
||||
@@ -174,6 +224,22 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
|
||||
<div>
|
||||
Timeline: {formatShortDate(row.plannedEndDate)} · {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
|
||||
</div>
|
||||
{row.derivation ? (
|
||||
<>
|
||||
<div>
|
||||
Spend basis: {row.derivation.calendarContextCount} calendar bases · {row.derivation.holidayAwareAssignmentCount} holiday-aware
|
||||
{row.derivation.fallbackAssignmentCount > 0 ? ` · ${row.derivation.fallbackAssignmentCount} fallback` : ""}
|
||||
</div>
|
||||
<div>
|
||||
Base {formatMoney(row.derivation.baseSpentCents)} {"->"} Effective {formatMoney(row.derivation.adjustedSpentCents)}
|
||||
</div>
|
||||
<div>
|
||||
Holidays -{formatMoney(row.derivation.publicHolidayCostDeductionCents)} ({formatDayEquivalent(row.derivation.publicHolidayDayEquivalent)}d)
|
||||
{" · "}
|
||||
Absence -{formatMoney(row.derivation.absenceCostDeductionCents)} ({formatDayEquivalent(row.derivation.absenceDayEquivalent)}d)
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{(row.calendarLocations ?? []).length > 0 ? (
|
||||
<div>
|
||||
Calendar basis: {(row.calendarLocations ?? [])
|
||||
|
||||
Reference in New Issue
Block a user