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