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
@@ -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">