feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user