344 lines
15 KiB
TypeScript
344 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import {
|
|
DOMAIN_COLORS,
|
|
DOMAIN_LABELS,
|
|
} from "~/components/analytics/computation-graph/domain-colors";
|
|
import { useComputationGraphData } from "~/components/analytics/computation-graph/useComputationGraphData";
|
|
import ComputationGraph2D from "~/components/analytics/ComputationGraph2D";
|
|
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");
|
|
|
|
const {
|
|
viewMode, setViewMode,
|
|
resourceId, setResourceId,
|
|
month, setMonth,
|
|
projectId, setProjectId,
|
|
resources, projects,
|
|
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 ── */}
|
|
<div className="flex flex-wrap items-center gap-3 border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
|
{/* 2D / 3D Toggle */}
|
|
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600">
|
|
<button
|
|
onClick={() => setDimension("2d")}
|
|
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
dimension === "2d"
|
|
? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900"
|
|
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
|
} rounded-l-lg`}
|
|
>
|
|
2D
|
|
</button>
|
|
<button
|
|
onClick={() => setDimension("3d")}
|
|
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
dimension === "3d"
|
|
? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900"
|
|
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
|
} rounded-r-lg`}
|
|
>
|
|
3D
|
|
</button>
|
|
</div>
|
|
|
|
{/* View Mode Toggle */}
|
|
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600">
|
|
<button
|
|
onClick={() => setViewMode("resource")}
|
|
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
viewMode === "resource"
|
|
? "bg-blue-600 text-white"
|
|
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
|
} rounded-l-lg`}
|
|
>
|
|
Resource View
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode("project")}
|
|
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
viewMode === "project"
|
|
? "bg-blue-600 text-white"
|
|
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
|
} rounded-r-lg`}
|
|
>
|
|
Project View
|
|
</button>
|
|
</div>
|
|
|
|
{/* Selectors */}
|
|
{viewMode === "resource" ? (
|
|
<>
|
|
<select
|
|
value={resourceId}
|
|
onChange={(e) => setResourceId(e.target.value)}
|
|
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
|
|
>
|
|
<option value="">Select Resource...</option>
|
|
{resources.map((r: { id: string; displayName: string; eid: string }) => (
|
|
<option key={r.id} value={r.id}>
|
|
{r.displayName} ({r.eid})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
type="month"
|
|
value={month}
|
|
onChange={(e) => setMonth(e.target.value)}
|
|
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
|
|
/>
|
|
</>
|
|
) : (
|
|
<select
|
|
value={projectId}
|
|
onChange={(e) => setProjectId(e.target.value)}
|
|
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
|
|
>
|
|
<option value="">Select Project...</option>
|
|
{(Array.isArray(projects) ? projects : []).map((p: { id: string; name: string; shortCode?: string | null }) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.shortCode ? `${p.shortCode} — ` : ""}{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
|
|
{/* Meta info */}
|
|
{graphData.nodes.length > 0 && (
|
|
<span className="ml-auto text-xs text-zinc-500">
|
|
{graphData.nodes.length} nodes, {graphData.links.length} links
|
|
</span>
|
|
)}
|
|
|
|
{/* Clear highlight */}
|
|
{highlightedNodes && (
|
|
<button
|
|
onClick={() => setHighlightedNodes(null)}
|
|
className="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-300"
|
|
>
|
|
Clear highlight
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Main Area ── */}
|
|
<div className="relative flex flex-1 overflow-hidden">
|
|
{/* Domain Filter Sidebar */}
|
|
<div className="flex w-48 flex-col gap-1 border-r border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900">
|
|
<span className="mb-1 text-xs font-semibold uppercase text-zinc-500">Domains</span>
|
|
{activeDomains.map((domain) => (
|
|
<button
|
|
key={domain}
|
|
onClick={() => toggleDomain(domain)}
|
|
className={`flex items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors ${
|
|
domainFilter.has(domain)
|
|
? "text-zinc-400 line-through"
|
|
: "text-zinc-700 dark:text-zinc-300"
|
|
} hover:bg-zinc-200 dark:hover:bg-zinc-800`}
|
|
>
|
|
<span
|
|
className="inline-block h-3 w-3 rounded-full"
|
|
style={{
|
|
backgroundColor: domainFilter.has(domain) ? "#9ca3af" : DOMAIN_COLORS[domain],
|
|
}}
|
|
/>
|
|
{DOMAIN_LABELS[domain]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Graph View */}
|
|
<div className="flex-1 overflow-hidden">
|
|
{isLoading ? (
|
|
<div className="flex h-full items-center justify-center text-zinc-500">
|
|
Loading computation graph...
|
|
</div>
|
|
) : dimension === "2d" ? (
|
|
<ComputationGraph2D state={state} />
|
|
) : (
|
|
<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>
|
|
);
|
|
}
|