feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -11,6 +11,50 @@ import ComputationGraph3D from "~/components/analytics/ComputationGraph3D";
|
||||
|
||||
type Dimension = "2d" | "3d";
|
||||
|
||||
interface ResourceHolidayMeta {
|
||||
date: string;
|
||||
name: string;
|
||||
scope: string;
|
||||
calendarName: string | null;
|
||||
}
|
||||
|
||||
interface ResourceFactorMeta {
|
||||
weeklyAvailability?: Record<string, number>;
|
||||
baseWorkingDays?: number;
|
||||
effectiveWorkingDays?: number;
|
||||
baseAvailableHours?: number;
|
||||
effectiveAvailableHours?: number;
|
||||
publicHolidayCount?: number;
|
||||
publicHolidayWorkdayCount?: number;
|
||||
publicHolidayHoursDeduction?: number;
|
||||
absenceDayCount?: number;
|
||||
absenceHoursDeduction?: number;
|
||||
chargeableHours?: number;
|
||||
utilizationPct?: number;
|
||||
}
|
||||
|
||||
interface ResourceGraphMeta {
|
||||
resourceName?: string;
|
||||
resourceEid?: string;
|
||||
month?: string;
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
resolvedHolidays?: ResourceHolidayMeta[];
|
||||
factors?: ResourceFactorMeta;
|
||||
}
|
||||
|
||||
function formatNumber(value: number | undefined, digits = 1): string {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return "—";
|
||||
}
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default function ComputationGraphClient() {
|
||||
const state = useComputationGraphData();
|
||||
const [dimension, setDimension] = useState<Dimension>("2d");
|
||||
@@ -24,10 +68,34 @@ export default function ComputationGraphClient() {
|
||||
isLoading,
|
||||
activeDomains,
|
||||
graphData,
|
||||
rawData,
|
||||
highlightedNodes, setHighlightedNodes,
|
||||
domainFilter, toggleDomain,
|
||||
} = state;
|
||||
|
||||
const resourceMeta = viewMode === "resource"
|
||||
? (rawData?.meta as ResourceGraphMeta | undefined)
|
||||
: undefined;
|
||||
const resourceFactors = resourceMeta?.factors;
|
||||
const weeklyAvailabilityEntries: Array<[string, number | undefined]> = resourceFactors?.weeklyAvailability
|
||||
? [
|
||||
["Mo", resourceFactors.weeklyAvailability.monday],
|
||||
["Di", resourceFactors.weeklyAvailability.tuesday],
|
||||
["Mi", resourceFactors.weeklyAvailability.wednesday],
|
||||
["Do", resourceFactors.weeklyAvailability.thursday],
|
||||
["Fr", resourceFactors.weeklyAvailability.friday],
|
||||
["Sa", resourceFactors.weeklyAvailability.saturday],
|
||||
["So", resourceFactors.weeklyAvailability.sunday],
|
||||
]
|
||||
: [];
|
||||
const weeklyAvailability = resourceFactors?.weeklyAvailability
|
||||
? weeklyAvailabilityEntries
|
||||
.filter((entry): entry is [string, number] => typeof entry[1] === "number" && entry[1] > 0)
|
||||
.map(([label, hours]) => `${label} ${formatNumber(hours, 1)}h`)
|
||||
.join(" · ")
|
||||
: "—";
|
||||
const topHolidays = resourceMeta?.resolvedHolidays?.slice(0, 6) ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
{/* ── Header Bar ── */}
|
||||
@@ -173,6 +241,102 @@ export default function ComputationGraphClient() {
|
||||
<ComputationGraph3D state={state} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{viewMode === "resource" && resourceMeta && (
|
||||
<aside className="w-[24rem] overflow-y-auto border-l border-zinc-200 bg-white/90 p-4 dark:border-zinc-700 dark:bg-zinc-950/90">
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Bezugsgroessen</div>
|
||||
<div className="mt-2 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{resourceMeta.resourceName ?? "Resource"}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">{resourceMeta.resourceEid ?? "—"} · {resourceMeta.month ?? month}</div>
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Land</div>
|
||||
<div>{resourceMeta.countryName ?? resourceMeta.countryCode ?? "—"}{resourceMeta.countryCode ? ` (${resourceMeta.countryCode})` : ""}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Bundesland / Region</div>
|
||||
<div>{resourceMeta.federalState ?? "—"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Ort / Metro</div>
|
||||
<div>{resourceMeta.metroCityName ?? "—"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Wochenverfuegbarkeit</div>
|
||||
<div>{weeklyAvailability}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Feiertagsbasis</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{resourceFactors?.publicHolidayCount ?? 0} Feiertage, {resourceFactors?.publicHolidayWorkdayCount ?? 0} wirksam
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{topHolidays.length > 0 ? topHolidays.map((holiday) => (
|
||||
<div
|
||||
key={`${holiday.date}-${holiday.name}`}
|
||||
className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm dark:border-zinc-800 dark:bg-zinc-950"
|
||||
>
|
||||
<div className="font-medium text-zinc-900 dark:text-zinc-100">{holiday.name}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{holiday.date} · {holiday.scope} · {holiday.calendarName ?? "Kalender"}
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="rounded-lg border border-dashed border-zinc-200 px-3 py-2 text-sm text-zinc-500 dark:border-zinc-800">
|
||||
Keine aufgeloesten Feiertage im gewaehlten Monat.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Herleitung</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="rounded-lg bg-white px-3 py-2 text-sm dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">SAH Formel</div>
|
||||
<div className="font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{formatNumber(resourceFactors?.baseAvailableHours)}h - {formatNumber(resourceFactors?.publicHolidayHoursDeduction)}h - {formatNumber(resourceFactors?.absenceHoursDeduction)}h = {formatNumber(resourceFactors?.effectiveAvailableHours)}h
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Basistage</div>
|
||||
<div>{formatNumber(resourceFactors?.baseWorkingDays, 0)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Effektive Tage</div>
|
||||
<div>{formatNumber(resourceFactors?.effectiveWorkingDays, 0)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Feiertagsabzug</div>
|
||||
<div>{formatNumber(resourceFactors?.publicHolidayHoursDeduction)}h</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Abwesenheitsabzug</div>
|
||||
<div>{formatNumber(resourceFactors?.absenceHoursDeduction)}h</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Chargeable Hours</div>
|
||||
<div>{formatNumber(resourceFactors?.chargeableHours)}h</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Auslastung</div>
|
||||
<div>{formatNumber(resourceFactors?.utilizationPct)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,12 +6,19 @@ import {
|
||||
RESOURCE_VIEW_DOMAINS,
|
||||
PROJECT_VIEW_DOMAINS,
|
||||
type Domain,
|
||||
type GraphLink,
|
||||
type GraphNode,
|
||||
} from "./domain-colors";
|
||||
import { buildForceGraphData, getConnectedNodeIds, type PositionedNode, type ForceGraphData } from "./graph-data";
|
||||
|
||||
export type ViewMode = "resource" | "project";
|
||||
|
||||
export interface ComputationGraphResponse {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ComputationGraphState {
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (m: ViewMode) => void;
|
||||
@@ -26,6 +33,7 @@ export interface ComputationGraphState {
|
||||
isLoading: boolean;
|
||||
activeDomains: Domain[];
|
||||
graphData: ForceGraphData;
|
||||
rawData: ComputationGraphResponse | null;
|
||||
highlightedNodes: Set<string> | null;
|
||||
setHighlightedNodes: (s: Set<string> | null) => void;
|
||||
hoveredNode: PositionedNode | null;
|
||||
@@ -144,6 +152,7 @@ export function useComputationGraphData(): ComputationGraphState {
|
||||
isLoading,
|
||||
activeDomains,
|
||||
graphData,
|
||||
rawData: (rawData as ComputationGraphResponse | undefined) ?? null,
|
||||
highlightedNodes,
|
||||
setHighlightedNodes,
|
||||
hoveredNode,
|
||||
|
||||
Reference in New Issue
Block a user