feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
@@ -158,6 +158,12 @@ export function DashboardClient() {
<WidgetContainer
title={widget.title ?? getWidget(widget.type).label}
description={getWidget(widget.type).description}
showDetails={widget.config.showDetails === true}
onToggleDetails={() =>
updateWidgetConfig(widget.id, {
showDetails: widget.config.showDetails !== true,
})
}
onRemove={() => removeWidget(widget.id)}
>
{renderWidget(widget.type, widget.config, (update) =>
@@ -8,9 +8,19 @@ interface WidgetContainerProps {
onRemove: () => void;
children: React.ReactNode;
isDragging?: boolean;
showDetails?: boolean;
onToggleDetails?: () => void;
}
export function WidgetContainer({ title, description, onRemove, children, isDragging }: WidgetContainerProps) {
export function WidgetContainer({
title,
description,
onRemove,
children,
isDragging,
showDetails = false,
onToggleDetails,
}: WidgetContainerProps) {
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
@@ -19,14 +29,12 @@ export function WidgetContainer({ title, description, onRemove, children, isDrag
className={`flex flex-col h-full rounded-xl border overflow-hidden transition-all duration-200 ${
isDragging
? "shadow-xl border-brand-400 dark:border-brand-500 scale-[1.01] ring-2 ring-brand-400/30"
: "bg-white dark:bg-gray-900 border-gray-200/80 dark:border-gray-700/60 shadow-sm hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600"
: "border-gray-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.95),rgba(255,255,255,0.98))] shadow-sm hover:shadow-md hover:border-gray-300 dark:border-gray-700/60 dark:bg-[linear-gradient(180deg,rgba(17,24,39,0.96),rgba(17,24,39,0.92))] dark:hover:border-gray-600"
}`}
>
{/* Header — clean, no background separation */}
<div className="flex items-center justify-between px-4 pt-3.5 pb-2 shrink-0 cursor-grab active:cursor-grabbing widget-drag-handle group">
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3 px-4 pt-3.5 pb-3 shrink-0 widget-drag-handle group">
<div className="min-w-0 flex-1 cursor-grab active:cursor-grabbing">
<div className="flex items-center gap-2">
{/* Drag grip dots */}
<svg
className="w-3.5 h-5 text-gray-300 dark:text-gray-600 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
viewBox="0 0 14 20"
@@ -39,32 +47,58 @@ export function WidgetContainer({ title, description, onRemove, children, isDrag
<circle cx="4" cy="16" r="1.5" />
<circle cx="10" cy="16" r="1.5" />
</svg>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">{title}</span>
<span className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{title}
</span>
{showDetails ? (
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-brand-700 dark:bg-brand-500/10 dark:text-brand-300">
Details
</span>
) : null}
</div>
{description && (
<p className="text-[11px] text-gray-400 dark:text-gray-500 truncate mt-0.5 ml-[22px]">{description}</p>
<p className="ml-[22px] mt-1 line-clamp-2 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="ml-2 p-1.5 text-gray-300 dark:text-gray-600 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg transition-colors shrink-0 opacity-0 group-hover:opacity-100"
title="Remove widget"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="flex items-center gap-2 shrink-0">
{onToggleDetails ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleDetails();
}}
className={`rounded-xl border px-3 py-1.5 text-[11px] font-semibold transition ${
showDetails
? "border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 dark:border-brand-500/30 dark:bg-brand-500/10 dark:text-brand-300"
: "border-gray-200 bg-white/80 text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-400 dark:hover:text-gray-200"
}`}
title={showDetails ? "Hide details" : "Show details"}
>
{showDetails ? "Details on" : "Details off"}
</button>
) : null}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-red-50 hover:text-red-500 dark:text-gray-600 dark:hover:bg-red-950/30 dark:hover:text-red-400"
title="Remove widget"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Subtle separator */}
<div className="mx-4 border-t border-gray-100 dark:border-gray-800" />
<div className="mx-4 border-t border-gray-200/80 dark:border-gray-800" />
{/* Body */}
<div className="flex-1 overflow-auto p-4">{children}</div>
<div className="flex-1 overflow-auto p-4 pt-3">{children}</div>
</motion.div>
);
}
@@ -19,7 +19,67 @@ function textColorClass(pct: number): string {
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[]>(
@@ -39,7 +99,7 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
const clientId = (config.clientId as string) ?? "";
const rows = useMemo(() => {
const all = data ?? [];
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;
@@ -47,6 +107,21 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
});
}, [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">
@@ -75,6 +150,28 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
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">
@@ -86,7 +183,7 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
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="Monthly burn rate based on currently active allocations" />
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" />
@@ -96,11 +193,41 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
<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-[140px] truncate">
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
<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">
<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
@@ -112,14 +239,37 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
{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">
{row.burnRate > 0
? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC`
: "\u2014"}
<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">
{row.estimatedExhaustionDate ?? "\u2014"}
<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>
))}
@@ -36,8 +36,91 @@ type ChargeabilityRow = {
chargeabilityTarget: number;
actualChargeability: number;
expectedChargeability: number;
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
derivation?: {
weeklyAvailabilityHours: number;
baseWorkingDays: number;
effectiveWorkingDayEquivalent: number;
baseAvailableHours: number;
effectiveAvailableHours: number;
publicHolidayCount: number;
publicHolidayWorkdayCount: number;
publicHolidayHoursDeduction: number;
absenceDayEquivalent: number;
absenceHoursDeduction: number;
actualBookedHours: number;
expectedBookedHours: number;
targetBookedHours: number;
unassignedHours: number;
};
};
function formatHours(value: number | undefined): string {
if (value === undefined) return "—";
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}h`;
}
function formatDayEquivalent(value: number | undefined): string {
if (value === undefined) return "—";
return Number.isInteger(value) ? `${value}` : value.toFixed(1);
}
function MetricPill({ label, value }: { label: string; value: string }) {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
<span className="text-gray-400 dark:text-gray-500">{label}</span>
<span className="text-gray-700 dark:text-gray-200">{value}</span>
</span>
);
}
function formatLocation(row: ChargeabilityRow): string {
const parts = [row.countryCode ?? row.countryName ?? null, row.federalState ?? null, row.metroCityName ?? null]
.filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
}
function ChargeabilityContextLine({ row }: { row: ChargeabilityRow }) {
const derivation = row.derivation;
if (!derivation) {
return null;
}
return (
<div className="mt-1.5 space-y-1.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
<div className="flex flex-wrap gap-1">
<MetricPill label="Loc" value={formatLocation(row)} />
<MetricPill label="Week" value={formatHours(derivation.weeklyAvailabilityHours)} />
<MetricPill label="Target" value={formatHours(derivation.targetBookedHours)} />
</div>
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2">
<div>
Days {formatDayEquivalent(derivation.baseWorkingDays)} {"->"} {formatDayEquivalent(derivation.effectiveWorkingDayEquivalent)}
</div>
<div>
Holidays {derivation.publicHolidayWorkdayCount}/{derivation.publicHolidayCount} ({formatHours(derivation.publicHolidayHoursDeduction)})
</div>
<div>
Base {formatHours(derivation.baseAvailableHours)} {"->"} Effective {formatHours(derivation.effectiveAvailableHours)}
</div>
<div>
Absence {formatDayEquivalent(derivation.absenceDayEquivalent)} ({formatHours(derivation.absenceHoursDeduction)})
</div>
<div>
Actual {formatHours(derivation.actualBookedHours)} · Expected {formatHours(derivation.expectedBookedHours)}
</div>
<div>
Free {formatHours(derivation.unassignedHours)}
</div>
</div>
</div>
);
}
function FilterDropdown({ label, children }: { label: string; children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
@@ -74,7 +157,13 @@ function FilterDropdown({ label, children }: { label: string; children: ReactNod
}
export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetProps) {
const config = _config as { topN?: number; watchlistThreshold?: number; chapter?: string; includeProposed?: boolean };
const config = _config as {
topN?: number;
watchlistThreshold?: number;
chapter?: string;
includeProposed?: boolean;
showDetails?: boolean;
};
const { chapters } = useWidgetFilterOptions();
const widgetFilters = useMemo<WidgetFilter[]>(
@@ -86,6 +175,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
);
const includeProposed = !!config.includeProposed;
const showDetails = !!config.showDetails;
const chapterFilter = (config.chapter as string) ?? "";
const [showDeparted, setShowDeparted] = useState(false);
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
@@ -266,7 +356,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
<p className="text-xs text-gray-400 flex items-center gap-1">
Period: {month}
<InfoTooltip
content="Chargeability is calculated for the current calendar month. Available hours are based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
content="Chargeability is calculated for the current calendar month. Available hours are derived from each person's weekly schedule and reduced by regional public holidays plus approved absences. Watchlist threshold: 15 percentage points below target."
width="w-72"
/>
</p>
@@ -330,7 +420,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white flex items-center">
Top Chargeability
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours / total available hours." />
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours divided by holiday- and absence-adjusted available hours." />
<span className="ml-1 font-normal normal-case text-gray-400">
{visibleTop.length}/{top.length}
</span>
@@ -390,18 +480,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
{visibleTop.map((r, i) => (
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[120px]">
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[240px] align-top">
<div className="truncate">
<span title={r.displayName}>{r.displayName}</span>
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
</div>
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
<UtilizationBar percent={r.actualChargeability} />
</td>
<td className="px-2 py-1 text-right font-semibold text-green-700 dark:text-green-400">
<AnimatedNumber value={r.actualChargeability} suffix="%" />
<td className="px-2 py-1 text-right font-semibold text-green-700 dark:text-green-400 align-top">
<div>
<AnimatedNumber value={r.actualChargeability} suffix="%" />
</div>
{showDetails ? (
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
</div>
) : null}
</td>
<td className="px-2 py-1 text-right text-gray-400">
<AnimatedNumber value={r.expectedChargeability} suffix="%" />
<td className="px-2 py-1 text-right text-gray-400 align-top">
<div>
<AnimatedNumber value={r.expectedChargeability} suffix="%" />
</div>
{showDetails ? (
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
{formatHours(r.derivation?.expectedBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
</div>
) : null}
</td>
</tr>
))}
@@ -473,18 +578,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
<tbody className="divide-y divide-gray-50">
{visibleWatchlist.map((r) => (
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[140px]">
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[240px] align-top">
<div className="truncate">
<span title={r.displayName}>{r.displayName}</span>
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
</div>
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
<UtilizationBar percent={r.actualChargeability} />
</td>
<td className="px-2 py-1 text-right font-semibold text-red-600 dark:text-red-400">
<AnimatedNumber value={r.actualChargeability} suffix="%" />
<td className="px-2 py-1 text-right font-semibold text-red-600 dark:text-red-400 align-top">
<div>
<AnimatedNumber value={r.actualChargeability} suffix="%" />
</div>
{showDetails ? (
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
</div>
) : null}
</td>
<td className="px-2 py-1 text-right text-gray-400">
<AnimatedNumber value={r.chargeabilityTarget} suffix="%" />
<td className="px-2 py-1 text-right text-gray-400 align-top">
<div>
<AnimatedNumber value={r.chargeabilityTarget} suffix="%" />
</div>
{showDetails ? (
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
Target {formatHours(r.derivation?.targetBookedHours)} · Free {formatHours(r.derivation?.unassignedHours)}
</div>
) : null}
</td>
</tr>
))}
@@ -8,7 +8,53 @@ import { ProgressRing } from "~/components/ui/ProgressRing.js";
type GroupBy = "project" | "person" | "chapter";
type DemandRow = {
id: string;
name: string;
shortCode: string;
allocatedHours: number;
requiredFTEs: number;
resourceCount: number;
derivation?: {
periodStart: string;
periodEnd: string;
periodWorkingHoursBase: number;
requiredHours: number | null;
requiredFTEs: number;
fillPct: number | null;
demandSource: "DEMAND_REQUIREMENTS" | "PROJECT_STAFFING_REQS" | "NONE";
calendarLocations: Array<{
countryCode: string | null;
federalState: string | null;
metroCityName: string | null;
resourceCount: number;
allocatedHours: number;
}>;
};
};
type DemandDerivation = NonNullable<DemandRow["derivation"]>;
type DemandCalendarLocation = DemandDerivation["calendarLocations"][number];
function formatHours(value: number | null | undefined): string {
if (value == null) return "—";
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}h`;
}
function formatLocation(location: DemandCalendarLocation): string {
const parts = [location.countryCode, location.federalState, location.metroCityName]
.filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
}
function formatDemandSource(source: DemandDerivation["demandSource"] | undefined): string {
if (source === "DEMAND_REQUIREMENTS") return "Source: Demand requirements";
if (source === "PROJECT_STAFFING_REQS") return "Source: Project staffing reqs";
return "No demand basis";
}
export function DemandWidget({ config, onConfigChange }: WidgetProps) {
const showDetails = config.showDetails === true;
const groupBy = (config.groupBy as GroupBy) || "project";
type SortKey = "name" | "allocatedHours" | "requiredFTEs" | "resourceCount";
@@ -48,7 +94,7 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
);
}
const rows = data ?? [];
const rows = (data ?? []) as DemandRow[];
const sorted = [...rows].sort((a, b) => {
const mult = sortDir === "asc" ? 1 : -1;
@@ -144,37 +190,84 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
<tbody className="divide-y divide-gray-100">
{sorted.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900 max-w-[200px] truncate">
{groupBy === "project" ? (
<span><span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>{row.name}</span>
) : (
row.name
)}
<td className="px-3 py-2 text-gray-900 max-w-[280px] align-top">
<div className="font-medium truncate">
{groupBy === "project" ? (
<span><span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>{row.name}</span>
) : (
row.name
)}
</div>
{showDetails && groupBy === "project" && 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>
{row.derivation.calendarLocations.length > 2 ? (
<div>+ {row.derivation.calendarLocations.length - 2} more calendar contexts</div>
) : null}
</div>
) : null}
</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 ? (
<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>
</div>
) : null}
</td>
<td className="px-3 py-2 text-right text-gray-700">{row.allocatedHours}h</td>
{groupBy === "project" && (
<td className="px-3 py-2 text-right text-gray-700">
<td className="px-3 py-2 text-right align-top text-gray-700">
{(() => {
const ftes = row.requiredFTEs as unknown as number;
if (ftes <= 0) return "—";
const requiredHours = ftes * 22 * 3 * 8;
const fillPct = Math.min(100, Math.round((row.allocatedHours / requiredHours) * 100));
const isBelowTarget = row.allocatedHours / 8 < ftes * 22 * 3;
const requiredHours = row.derivation?.requiredHours ?? null;
const rawFillPct = row.derivation?.fillPct ?? null;
const fillPct = Math.min(100, rawFillPct ?? 0);
const isBelowTarget = rawFillPct !== null ? rawFillPct < 100 : false;
const ringColor = isBelowTarget
? "var(--color-red-500, #ef4444)"
: "var(--color-green-500, #22c55e)";
return (
<span className="inline-flex items-center gap-1.5">
<ProgressRing value={fillPct} size={22} strokeWidth={2.5} color={ringColor} />
<span className={isBelowTarget ? "text-red-600 font-semibold" : "text-green-700"}>
{ftes} FTE
<div className="inline-flex flex-col items-end gap-1">
<span className="inline-flex items-center gap-1.5">
<ProgressRing value={fillPct} size={22} strokeWidth={2.5} color={ringColor} />
<span className={isBelowTarget ? "text-red-600 font-semibold" : "text-green-700"}>
{ftes} FTE
</span>
</span>
</span>
{showDetails ? (
<div className="space-y-0.5 text-[10px] leading-4 text-gray-500">
<div>{formatHours(row.allocatedHours)} / {formatHours(requiredHours)}</div>
<div>{rawFillPct == null ? "—" : `${rawFillPct}% coverage`} · {formatHours(row.derivation?.periodWorkingHoursBase)} per 1.0 FTE</div>
<div>{formatDemandSource(row.derivation?.demandSource)}</div>
</div>
) : null}
</div>
);
})()}
</td>
)}
<td className="px-3 py-2 text-right text-gray-500">{row.resourceCount}</td>
<td className="px-3 py-2 text-right align-top text-gray-500">
<div>{row.resourceCount}</div>
{showDetails && groupBy === "project" && 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>
) : null}
</td>
</tr>
))}
</tbody>
@@ -1,55 +1,172 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceLine,
ResponsiveContainer,
Legend,
} from "recharts";
import { useMemo, useState } from "react";
const COLORS = [
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6",
];
type PeakTimesChartRow = {
period: string;
label: string;
bookedHours: number;
capacityHours: number;
utilizationPct: number;
remainingHours: number;
overbookedHours: number;
isCurrentPeriod: boolean;
};
interface PeakTimesChartProps {
chartData: Record<string, number | string>[];
groups: string[];
rows: PeakTimesChartRow[];
selectedPeriod: string | null;
onSelectedPeriodChange?: (period: string) => void;
}
export default function PeakTimesChart({ chartData, groups }: PeakTimesChartProps) {
if (chartData.length === 0) {
function formatHours(value: number): string {
return new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
}
function utilizationBarTone(utilizationPct: number): string {
if (utilizationPct > 100) return "bg-red-500";
if (utilizationPct > 75) return "bg-emerald-500";
if (utilizationPct >= 50) return "bg-amber-400";
return "bg-rose-400";
}
function utilizationTextTone(utilizationPct: number): string {
if (utilizationPct > 100) return "text-red-600 dark:text-red-300";
if (utilizationPct > 75) return "text-emerald-600 dark:text-emerald-300";
if (utilizationPct >= 50) return "text-amber-600 dark:text-amber-300";
return "text-rose-600 dark:text-rose-300";
}
export default function PeakTimesChart({
rows,
selectedPeriod,
onSelectedPeriodChange,
}: PeakTimesChartProps) {
const [hoveredPeriod, setHoveredPeriod] = useState<string | null>(null);
const fallbackPeriod = selectedPeriod && rows.some((row) => row.period === selectedPeriod)
? selectedPeriod
: rows[0]?.period ?? null;
const activePeriod = hoveredPeriod ?? fallbackPeriod;
const activeRow = useMemo(
() => rows.find((row) => row.period === activePeriod) ?? rows[0] ?? null,
[activePeriod, rows],
);
const chartMaxPct = useMemo(() => {
const maxUtilization = Math.max(100, ...rows.map((row) => row.utilizationPct));
return Math.max(120, Math.ceil(maxUtilization / 20) * 20);
}, [rows]);
const tickValues = useMemo(() => {
const base = [0, 50, 100];
return chartMaxPct > 100 ? [...base, chartMaxPct] : base;
}, [chartMaxPct]);
const referenceLineBottom = (100 / chartMaxPct) * 100;
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No allocation data in selected period.
<div className="flex h-full items-center justify-center rounded-[22px] border border-dashed border-slate-200 bg-slate-50/80 text-sm text-slate-400 dark:border-slate-700 dark:bg-slate-900/40">
No allocation data in the selected horizon.
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="period" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<ReferenceLine
{...({ dataKey: "capacity" } as any)}
stroke="#ef4444"
strokeDasharray="5 5"
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }}
/>
{groups.map((g, i) => (
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} />
))}
</BarChart>
</ResponsiveContainer>
<div className="flex h-full min-h-[15rem] flex-col rounded-[22px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] p-3 shadow-sm dark:border-slate-700/70 dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.98))]">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200/70 pb-2 dark:border-slate-700/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Overall Utilization
</div>
{activeRow ? (
<div className="min-w-0 text-right">
<div className={`truncate text-sm font-semibold ${utilizationTextTone(activeRow.utilizationPct)}`}>
{activeRow.label} · {activeRow.utilizationPct}%
</div>
<div className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{formatHours(activeRow.bookedHours)}h / {formatHours(activeRow.capacityHours)}h
</div>
</div>
) : null}
</div>
<div className="mt-3 flex min-h-[12rem] flex-1 gap-2">
<div className="flex w-8 shrink-0 flex-col justify-between pb-6 text-right text-[9px] font-medium text-slate-400">
{[...tickValues].reverse().map((tick) => (
<span key={tick}>{tick}%</span>
))}
</div>
<div className="relative min-w-0 flex-1">
<div className="pointer-events-none absolute inset-0 bottom-6">
{[...tickValues].reverse().map((tick) => {
const bottom = (tick / chartMaxPct) * 100;
return (
<div
key={tick}
className="absolute left-0 right-0 border-t border-dashed border-slate-200/80 dark:border-slate-700/50"
style={{ bottom: `${bottom}%` }}
/>
);
})}
<div
className="absolute left-0 right-0 border-t border-slate-300/90 dark:border-slate-500/80"
style={{ bottom: `${referenceLineBottom}%` }}
/>
</div>
<div
className="grid h-full items-end gap-1.5 pb-6 sm:gap-2"
style={{ gridTemplateColumns: `repeat(${rows.length}, minmax(0, 1fr))` }}
>
{rows.map((row) => {
const height = Math.min((row.utilizationPct / chartMaxPct) * 100, 100);
const isActive = row.period === activePeriod;
const isPinned = row.period === fallbackPeriod;
return (
<button
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`}
onMouseEnter={() => setHoveredPeriod(row.period)}
onMouseLeave={() => setHoveredPeriod((current) => (current === row.period ? null : current))}
onClick={() => onSelectedPeriodChange?.(row.period)}
style={{
backgroundColor: isPinned
? "rgba(14, 165, 233, 0.08)"
: isActive
? "rgba(148, 163, 184, 0.08)"
: "transparent",
}}
>
<div className="relative flex min-h-0 flex-1 w-full items-end justify-center px-0.5">
<div className="relative h-full w-full max-w-[34px] sm:max-w-[42px]">
<div className="absolute inset-x-0 bottom-0 h-full rounded-t-xl bg-slate-100 dark:bg-slate-800/80" />
<div
className={`absolute inset-x-0 bottom-0 rounded-t-xl transition-all duration-150 ${utilizationBarTone(row.utilizationPct)} ${
isActive ? "opacity-100" : "opacity-80 group-hover:opacity-100"
}`}
style={{ height: `${Math.max(height, row.utilizationPct > 0 ? 6 : 0)}%` }}
/>
</div>
</div>
<div className="mt-2 min-w-0 shrink-0">
<div className="truncate text-center text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500 dark:text-slate-400">
{row.label}
</div>
</div>
</button>
);
})}
</div>
</div>
</div>
</div>
);
}
@@ -1,5 +1,6 @@
"use client";
import { useMemo } from "react";
import dynamic from "next/dynamic";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
@@ -10,84 +11,249 @@ const PeakTimesChart = dynamic(
{ ssr: false, loading: () => <div className="flex-1 shimmer-skeleton rounded-xl" /> },
);
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
const granularity = (config.granularity as "week" | "month") || "month";
const groupBy = (config.groupBy as "project" | "chapter" | "resource") || "project";
type PeakDepartmentRow = {
name: string;
hours: number;
capacityHours: number;
remainingHours: number;
overbookedHours: number;
utilizationPct: number;
};
type PeakPeriodRow = {
period: string;
label: string;
bookedHours: number;
capacityHours: number;
remainingHours: number;
overbookedHours: number;
utilizationPct: number;
isCurrentPeriod: boolean;
groups: PeakDepartmentRow[];
};
function formatHours(value: number): string {
return new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
}
function formatMonthLabel(periodStart: string | undefined, fallback: string): string {
if (!periodStart) {
return fallback;
}
const date = new Date(`${periodStart}T00:00:00.000Z`);
if (Number.isNaN(date.getTime())) {
return fallback;
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
year: "2-digit",
timeZone: "UTC",
}).format(date);
}
function utilizationTone(utilizationPct: number): string {
if (utilizationPct >= 100) return "bg-red-500";
if (utilizationPct >= 85) return "bg-amber-400";
return "bg-emerald-500";
}
function utilizationTextTone(utilizationPct: number): string {
if (utilizationPct >= 100) return "text-red-600 dark:text-red-300";
if (utilizationPct >= 85) return "text-amber-600 dark:text-amber-300";
return "text-emerald-600 dark:text-emerald-300";
}
function aggregateDepartmentRows(rows: PeakDepartmentRow[], limit = 6): PeakDepartmentRow[] {
if (rows.length <= limit) {
return rows;
}
const visibleRows = rows.slice(0, limit - 1);
const hiddenRows = rows.slice(limit - 1);
const hiddenHours = hiddenRows.reduce((sum, row) => sum + row.hours, 0);
const hiddenCapacityHours = hiddenRows.reduce((sum, row) => sum + row.capacityHours, 0);
const hiddenRemainingHours = hiddenRows.reduce((sum, row) => sum + row.remainingHours, 0);
const hiddenOverbookedHours = hiddenRows.reduce((sum, row) => sum + row.overbookedHours, 0);
return [
...visibleRows,
{
name: `Other (${hiddenRows.length})`,
hours: hiddenHours,
capacityHours: hiddenCapacityHours,
remainingHours: hiddenRemainingHours,
overbookedHours: hiddenOverbookedHours,
utilizationPct:
hiddenCapacityHours > 0 ? Math.round((hiddenHours / hiddenCapacityHours) * 100) : 0,
},
];
}
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString();
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0).toISOString();
const startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
const endDate = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 12, 0, 23, 59, 59, 999),
).toISOString();
const currentPeriodKey = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
const persistedPeriod = typeof config.activePeriod === "string" ? config.activePeriod : null;
const { data, isLoading } = trpc.dashboard.getPeakTimes.useQuery(
{ startDate, endDate, granularity, groupBy },
{ startDate, endDate, granularity: "month", groupBy: "chapter" },
{ staleTime: 120_000, placeholderData: (prev) => prev },
);
if (isLoading) {
const periods = useMemo<PeakPeriodRow[]>(
() =>
(data ?? []).map((period) => {
const derivation = period.derivation;
const bookedHours = period.bookedHours ?? derivation.bookedHours ?? period.totalHours;
const capacityHours = period.capacityHours ?? derivation.capacityHours ?? 0;
const remainingHours =
period.remainingHours ??
derivation.remainingCapacityHours ??
Math.max(capacityHours - bookedHours, 0);
const overbookedHours =
period.overbookedHours ??
derivation.overbookedHours ??
Math.max(bookedHours - capacityHours, 0);
const utilizationPct =
period.utilizationPct ??
derivation.utilizationPct ??
(capacityHours > 0 ? Math.round((bookedHours / capacityHours) * 100) : 0);
return {
period: period.period,
label: formatMonthLabel(period.periodStart ?? derivation.periodStart, period.period),
bookedHours,
capacityHours,
remainingHours,
overbookedHours,
utilizationPct,
isCurrentPeriod: period.period === currentPeriodKey,
groups: (period.groups ?? [])
.map((group) => {
const groupCapacityHours = group.capacityHours ?? 0;
const groupRemainingHours =
group.remainingHours ?? Math.max(groupCapacityHours - group.hours, 0);
const groupOverbookedHours =
group.overbookedHours ?? Math.max(group.hours - groupCapacityHours, 0);
const groupUtilizationPct =
group.utilizationPct ??
(groupCapacityHours > 0 ? Math.round((group.hours / groupCapacityHours) * 100) : 0);
return {
name: group.name,
hours: group.hours,
capacityHours: groupCapacityHours,
remainingHours: groupRemainingHours,
overbookedHours: groupOverbookedHours,
utilizationPct: groupUtilizationPct,
};
})
.sort(
(left, right) =>
right.utilizationPct - left.utilizationPct ||
right.hours - left.hours ||
left.name.localeCompare(right.name),
),
};
}),
[currentPeriodKey, data],
);
const selectedPeriod =
(persistedPeriod && periods.some((period) => period.period === persistedPeriod) ? persistedPeriod : null) ??
(periods.some((period) => period.period === currentPeriodKey) ? currentPeriodKey : periods[0]?.period ?? null);
const selectedPeriodRow =
periods.find((period) => period.period === selectedPeriod) ?? periods[0] ?? null;
const currentPeriodRow =
periods.find((period) => period.period === currentPeriodKey) ?? selectedPeriodRow;
const peakPeriodRow = useMemo(
() =>
[...periods].sort(
(left, right) =>
right.utilizationPct - left.utilizationPct || right.bookedHours - left.bookedHours,
)[0] ?? null,
[periods],
);
const departmentRows = useMemo(
() => aggregateDepartmentRows(selectedPeriodRow?.groups ?? []),
[selectedPeriodRow],
);
if (isLoading && periods.length === 0) {
return (
<div className="flex flex-col gap-3 h-full pt-2">
<div className="flex gap-2">
<div className="h-7 w-28 shimmer-skeleton rounded-lg" />
<div className="h-7 w-28 shimmer-skeleton rounded-lg" />
</div>
<div className="flex items-end gap-1 flex-1 px-2">
{[...Array(12)].map((_, i) => (
<div
key={i}
className="flex-1 shimmer-skeleton rounded-t"
style={{ height: `${30 + Math.random() * 50}%` }}
/>
<div className="flex h-full flex-col gap-3 pt-2">
<div className="grid grid-cols-3 gap-2">
{[...Array(3)].map((_, index) => (
<div key={index} className="h-14 rounded-2xl shimmer-skeleton" />
))}
</div>
<div className="flex-1 rounded-[22px] shimmer-skeleton" />
<div className="h-32 rounded-[22px] shimmer-skeleton" />
</div>
);
}
const periods = data ?? [];
// Collect all group names
const allGroups = new Set<string>();
for (const p of periods) {
for (const g of p.groups) allGroups.add(g.name);
}
const groups = [...allGroups].slice(0, 10);
// Build recharts data
const chartData = periods.map((p) => {
const row: Record<string, number | string> = { period: p.period, capacity: p.capacityHours };
for (const g of p.groups) {
row[g.name] = g.hours;
}
return row;
});
return (
<div className="flex flex-col h-full gap-3">
{/* Controls + info */}
<div className="flex gap-2 items-center">
<select
value={granularity}
onChange={(e) => onConfigChange?.({ granularity: e.target.value })}
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="month">Monthly</option>
<option value="week">Weekly</option>
</select>
<select
value={groupBy}
onChange={(e) => onConfigChange?.({ groupBy: e.target.value })}
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="project">By Project</option>
<option value="chapter">By Chapter</option>
<option value="resource">By Resource</option>
</select>
<div className="flex h-full flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between gap-3">
<div className="grid min-w-0 flex-1 grid-cols-3 gap-2">
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Current
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-3">
<span className={`text-base font-semibold ${utilizationTextTone(currentPeriodRow?.utilizationPct ?? 0)}`}>
{currentPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{currentPeriodRow?.label ?? "No data"}
</span>
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Selected
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-3">
<span className={`text-base font-semibold ${utilizationTextTone(selectedPeriodRow?.utilizationPct ?? 0)}`}>
{selectedPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{selectedPeriodRow?.label ?? "Hover or pin"}
</span>
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Peak
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-3">
<span className={`text-base font-semibold ${utilizationTextTone(peakPeriodRow?.utilizationPct ?? 0)}`}>
{peakPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{peakPeriodRow?.label ?? "No data"}
</span>
</div>
</div>
</div>
<InfoTooltip
content={
<span>
Stacked bars = booked hours per group per period (last 2 months to next 6 months).<br />
Red dashed line = total capacity estimate (all active resources × available hours per day × working days).<br />
Bars exceeding the capacity line indicate over-allocation risk.
The top chart shows total booked load against effective capacity.<br />
The current month is marked with a blue accent.<br />
Hover any month to inspect details and click to pin the department breakdown.
</span>
}
width="w-80"
@@ -95,9 +261,72 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
/>
</div>
{/* Chart */}
<div className="flex-1 min-h-0">
<PeakTimesChart chartData={chartData} groups={groups} />
<div className="min-h-0 flex-1 lg:grid lg:grid-cols-[minmax(0,1.85fr)_minmax(18rem,0.95fr)] lg:gap-3">
<div className="min-h-0">
<PeakTimesChart
rows={periods}
selectedPeriod={selectedPeriod}
onSelectedPeriodChange={(period) => onConfigChange?.({ activePeriod: period })}
/>
</div>
<div className="mt-2 min-h-0 lg:mt-0">
<div className="flex h-full flex-col rounded-[22px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] p-3 shadow-sm dark:border-slate-700/70 dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.98))]">
<div className="flex flex-wrap items-start justify-between gap-2 border-b border-slate-200/70 pb-2 dark:border-slate-700/60">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Department Utilization
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{selectedPeriodRow?.label ?? "No active month"}
</div>
</div>
<div className="text-right text-[11px] text-slate-500 dark:text-slate-400">
<div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.bookedHours)}h booked` : "No load"}</div>
<div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.capacityHours)}h capacity` : ""}</div>
</div>
</div>
<div className="mt-3 min-h-0 flex-1 space-y-2 overflow-auto pr-1">
{departmentRows.length > 0 ? (
departmentRows.map((group) => {
const barWidth = Math.min(group.utilizationPct, 100);
return (
<div key={group.name} className="space-y-1">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 truncate text-xs font-medium text-slate-700 dark:text-slate-200">
{group.name}
</div>
<div className={`shrink-0 text-[11px] font-semibold ${utilizationTextTone(group.utilizationPct)}`}>
{group.utilizationPct}%
</div>
</div>
<div
className="relative h-2.5 overflow-visible rounded-full bg-slate-100 dark:bg-slate-800/80"
title={`${group.name}: ${group.utilizationPct}% utilization, ${formatHours(group.hours)}h booked, ${formatHours(group.capacityHours)}h capacity, ${formatHours(group.remainingHours)}h free, ${formatHours(group.overbookedHours)}h overbooked`}
>
<div
className={`h-full rounded-full ${utilizationTone(group.utilizationPct)}`}
style={{ width: `${barWidth}%` }}
/>
{group.overbookedHours > 0 ? (
<div
className="absolute right-0 top-0 h-full rounded-full bg-red-600/85"
style={{ width: `${Math.min(22, Math.max(8, group.utilizationPct - 100))}%` }}
/>
) : null}
</div>
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 px-3 py-4 text-sm text-slate-400 dark:border-slate-700 dark:bg-slate-900/40">
No department data in the selected month.
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
@@ -8,6 +8,7 @@ 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";
function healthDot(value: number): string {
if (value >= 70) return "bg-green-500";
@@ -21,7 +22,55 @@ function scoreBadge(score: number): string {
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";
}
export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
const showDetails = config.showDetails === true;
const { clients } = useWidgetFilterOptions();
const filters = useMemo<WidgetFilter[]>(
@@ -87,10 +136,10 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
<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 scored across three health dimensions" />
Project <InfoTooltip content="Active projects scored across three health dimensions including visible budget, staffing, and timeline basis." />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demands), Timeline health (within end date)" />
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demanded headcount), Timeline health (end date and remaining horizon)." />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
Shoring <InfoTooltip content="Offshore staffing ratio: percentage of hours from non-onshore resources. Color indicates threshold status." />
@@ -103,26 +152,66 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
<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-[160px] truncate">
<Link href={`/projects/${(row as any).id}`} className="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
<td className="px-3 py-2 text-gray-900 dark:text-gray-100 max-w-[320px]">
<Link href={`/projects/${(row as any).id}`} className="block hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
<div className="truncate font-medium">
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
</div>
</Link>
{showDetails ? (
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
<div>
Budget: {formatMoney(row.spentCents ?? 0)} spent
{row.budgetCents != null ? ` / ${formatMoney(row.budgetCents)} budget` : " / no budget"}
{row.remainingBudgetCents != null ? ` / ${formatMoney(row.remainingBudgetCents)} remaining` : ""}
</div>
<div>
Staffing: {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} HC
{typeof row.demandHeadcountOpen === "number" ? `, ${row.demandHeadcountOpen} open` : ""}
{typeof row.demandRequirementCount === "number" ? ` across ${row.demandRequirementCount} demands` : ""}
</div>
<div>
Timeline: {formatShortDate(row.plannedEndDate)} · {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
</div>
{(row.calendarLocations ?? []).length > 0 ? (
<div>
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`
: ""}
</div>
) : null}
</div>
) : null}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
title={`Budget: ${row.budgetHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
title={`Staffing: ${row.staffingHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
title={`Timeline: ${row.timelineHealth}%`}
/>
<div className="flex flex-col items-center justify-center gap-1 text-[11px] text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
title={`Budget: ${row.budgetHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
title={`Staffing: ${row.staffingHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
title={`Timeline: ${row.timelineHealth}%`}
/>
</div>
<div className="text-center tabular-nums">
B {row.budgetUtilizationPercent ?? 0}% used
</div>
{showDetails ? (
<div className="text-center tabular-nums">
S {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} · T {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
</div>
) : null}
</div>
</td>
<td className="px-3 py-2 text-center">
@@ -19,6 +19,8 @@ function StatCard({
value,
suffix,
sub,
details,
showDetails = false,
info,
accentColor,
delay = 0,
@@ -28,6 +30,8 @@ function StatCard({
value: number;
suffix?: string;
sub?: string;
details?: string[];
showDetails?: boolean;
info?: React.ReactNode;
accentColor?: "green" | "amber" | "red";
delay?: number;
@@ -66,13 +70,37 @@ function StatCard({
</div>
)}
{sub && <p className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">{sub}</p>}
{showDetails && details && details.length > 0 ? (
<div className="mt-2 space-y-1 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{details.map((detail) => (
<p key={detail}>{detail}</p>
))}
</div>
) : null}
</div>
</FadeIn>
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
function formatShortDate(value?: string | Date | null): string {
if (!value) {
return "n/a";
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return "n/a";
}
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
}
export function StatCardsWidget(props: Partial<WidgetProps> = {}) {
const showDetails = props.config?.showDetails === true;
const { data, isLoading } = trpc.dashboard.getOverview.useQuery(undefined, {
staleTime: 60_000,
placeholderData: (prev) => prev,
@@ -104,21 +132,33 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
<StatCard
label="Total Resources"
value={data.totalResources}
sub={`${data.activeResources} active`}
info="All resources in the system. Sub-line shows active resources only."
sub={`${data.activeResources} active / ${data.inactiveResources ?? Math.max(data.totalResources - data.activeResources, 0)} inactive`}
details={[
"Basis: all resource master records",
]}
showDetails={showDetails}
info="All resources in the system. Sub-line shows active versus inactive records."
delay={0}
/>
<StatCard
label="Active Projects"
value={data.activeProjects}
sub={`${data.totalProjects} total`}
sub={`${data.totalProjects} total / ${data.inactiveProjects ?? Math.max(data.totalProjects - data.activeProjects, 0)} non-active`}
details={[
"Basis: project status on the dashboard snapshot",
]}
showDetails={showDetails}
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`}
sub={`${data.activeAllocations} not cancelled / ${data.cancelledAllocations ?? Math.max(data.totalAllocations - data.activeAllocations, 0)} cancelled`}
details={[
"Basis: split allocation read model across explicit and legacy rows",
]}
showDetails={showDetails}
info="All allocation records ever created. 'Not cancelled' excludes allocations with status CANCELLED."
delay={0.1}
/>
@@ -127,7 +167,13 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
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."
details={[
`Remaining: ${formatMoney(data.budgetBasis?.remainingBudgetCents ?? (data.budgetSummary.totalBudgetCents - data.budgetSummary.totalCostCents))}`,
`Basis: ${data.budgetBasis?.trackedAssignmentCount ?? 0} non-cancelled assignments across ${data.budgetBasis?.budgetedProjects ?? 0} budgeted projects`,
`Window: ${formatShortDate(data.budgetBasis?.windowStart)} - ${formatShortDate(data.budgetBasis?.windowEnd)}`,
]}
showDetails={showDetails}
info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost uses the effective allocation cost basis including holiday-adjusted working capacity where available."
accentColor={budgetAccent}
delay={0.15}
ring={{ value: budgetPct, color: ACCENT_COLORS[budgetAccent] }}