Org assignment history, mobile support, and a correctness pass
Data model - employee_assignments records org placement over time (valid_from/valid_to), written by a trigger on `employees` rather than inside each RPC: ~70 `update employees` statements spread over fifteen migrations mean per-call bookkeeping would miss paths today and again with every future RPC. A partial unique index enforces the one-open-interval invariant the trigger relies on when closing the current row. - The Organigramm gains a Stichtag (default today). Membership comes from entry/exit/karenz, past placement from the new history, future placement projected from pending_org_changes. Placements predating the migration are backfilled with today's values and flagged as such in the UI, since employee_history only ever stored free text and cannot be reconstructed. Correctness - Reports and exports silently truncated at PostgREST's 1000-row cap (db.max_rows); employee_history is already past it at ~800 staff. Every whole-table read now pages explicitly. - XLSX date cells were a day early: ExcelJS converts a Date to an Excel serial straight off getTime(), so a Date built at local midnight lands on the previous day's serial in any positive-offset zone. - Date handling is pinned to Europe/Vienna throughout, and date-only strings are formatted without a Date round-trip. The dashboard's YTD window was built by round-tripping a local Date through toISOString(), which shifted it a day early and dropped 31 December entirely. - Export routes parsed measure/group/split/eventType with unchecked `as` casts, so an unknown value reached column headers as `undefined` and the Content-Disposition filename. Parsed against the label maps now, with the filename slugged as a backstop. - toXlsx keyed columns by header text, silently dropping the second of any two columns sharing a name — split columns take their header from data. - The org chart tree walks had no cycle guard; nothing in the schema forbids a manager_id cycle, and one would hang the tab rather than misreport. - The login page reflected ?error= verbatim, letting anyone put arbitrary text on the real sign-in screen; messages are looked up by code now. - React Flow needs elementsSelectable on, or it sets pointer-events:none on the whole node and the expand control stops responding. UI - Mobile: the shell was unusable below lg — a fixed 236px margin pushed content off-screen with no mobile navigation at all. The sidebar is now a drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so iOS stops zooming on focus, and form grids stack. - Org chart nodes redesigned: per-kind accent stripes and icons, vacant roles called out, expand control moved to the bottom edge carrying the child count. - Pagination is windowed; it previously rendered one link per page (54 for the employee list, unbounded for the audit log). - Positions page reduced to open positions with a single "Besetzen" action. - The employee Organisation tab links into the org chart focused on that person, reusing the chart's existing search-match highlighting. Also included, uncommitted until now - Dependants, HR notes, academic titles, split address fields, position validity and role/employment fields, with their migrations and UI. - Docker/compose deployment setup, data-model and security-review docs.
This commit is contained in:
76
components/orgchart/AsOfPicker.tsx
Normal file
76
components/orgchart/AsOfPicker.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
|
||||
type AsOfPickerProps = {
|
||||
asOf: string;
|
||||
today: string;
|
||||
/** Placements projected from effective-dated changes not yet applied. */
|
||||
projectedCount: number;
|
||||
/** Earliest date the assignment history covers; before that it is today's placement. */
|
||||
historyStartsAt: string | null;
|
||||
};
|
||||
|
||||
export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsOfPickerProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
function setAsOf(value: string | undefined) {
|
||||
const sp = new URLSearchParams(searchParams.toString());
|
||||
if (value && value !== today) sp.set("asOf", value);
|
||||
else sp.delete("asOf");
|
||||
router.push(sp.size > 0 ? `${pathname}?${sp}` : pathname, { scroll: false });
|
||||
}
|
||||
|
||||
const isToday = asOf === today;
|
||||
const isFuture = asOf > today;
|
||||
// Assignments only started being recorded when the history table was
|
||||
// introduced; asking for a date before that yields today's placement for
|
||||
// everyone, which is worth saying out loud rather than quietly implying.
|
||||
const beforeHistory = historyStartsAt !== null && asOf < historyStartsAt;
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border bg-white p-3">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<label htmlFor="orgchart-asof" className="flex items-center gap-1.5 text-sm font-semibold text-ink">
|
||||
<CalendarClock className="h-4 w-4 text-ink-muted" />
|
||||
Stichtag
|
||||
</label>
|
||||
<input
|
||||
id="orgchart-asof"
|
||||
type="date"
|
||||
value={asOf}
|
||||
onChange={(e) => setAsOf(e.target.value || undefined)}
|
||||
className="rounded border border-border px-3 py-2 text-sm"
|
||||
/>
|
||||
{!isToday && (
|
||||
<button type="button" onClick={() => setAsOf(undefined)} className="text-xs font-semibold text-brand-700 hover:underline">
|
||||
Heute
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-ink-muted">
|
||||
{isToday ? "Aktuelle Organisationsstruktur." : `Struktur zum ${fmtDate(asOf)}.`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isFuture && (
|
||||
<p className="mt-2 rounded bg-info-bg px-3 py-2 text-xs text-info-text">
|
||||
Vorschau: {projectedCount > 0
|
||||
? `${projectedCount} geplante Versetzung(en)/Reorganisation(en) sind eingerechnet.`
|
||||
: "Für diesen Zeitraum sind keine Versetzungen vorgemerkt."}{" "}
|
||||
Ein-/Austritte und Karenzen sind berücksichtigt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{beforeHistory && (
|
||||
<p className="mt-2 rounded bg-warning-bg px-3 py-2 text-xs text-warning-text">
|
||||
Vor dem {fmtDate(historyStartsAt)} wurde die Zuordnungshistorie noch nicht aufgezeichnet. Wer beschäftigt war,
|
||||
stimmt; Team und Vorgesetzte zeigen für diesen Stichtag die heutige Zuordnung.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,21 @@
|
||||
|
||||
import { ChevronDown, ChevronRight, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import type { OrgEmployee } from "./types";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
||||
import type { ChartNode, OrgEmployee } from "./types";
|
||||
|
||||
export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
type ViewMode = "list" | "graph";
|
||||
|
||||
export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmployee[]; focusId?: string | null }) {
|
||||
// Seeded with the focus target so their own reports are already unfolded;
|
||||
// the chain *above* them comes from ancestorExpandIds.
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => (focusId ? new Set([focusId]) : new Set()));
|
||||
const [query, setQuery] = useState("");
|
||||
const [mode, setMode] = useState<ViewMode>("list");
|
||||
const focusRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { childrenByManager, totalReportsById, root } = useMemo(() => {
|
||||
const byManager = new Map<string, OrgEmployee[]>();
|
||||
@@ -19,22 +27,33 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
|
||||
}
|
||||
for (const list of byManager.values()) list.sort((a, b) => a.last_name.localeCompare(b.last_name));
|
||||
|
||||
// Nothing in the schema forbids a manager_id cycle (A reports to B
|
||||
// reports to A), and a reorg that moves a lead under one of their own
|
||||
// reports would create one. Every walk below is recursive, so an
|
||||
// unguarded cycle is an infinite recursion that hangs the tab rather
|
||||
// than a wrong number — hence the on-path set.
|
||||
const totals = new Map<string, number>();
|
||||
function countTotal(id: string): number {
|
||||
if (totals.has(id)) return totals.get(id)!;
|
||||
const direct = byManager.get(id) ?? [];
|
||||
let total = direct.length;
|
||||
for (const child of direct) total += countTotal(child.id);
|
||||
function countTotal(id: string, path: Set<string>): number {
|
||||
const memo = totals.get(id);
|
||||
if (memo !== undefined) return memo;
|
||||
if (path.has(id)) return 0;
|
||||
path.add(id);
|
||||
let total = 0;
|
||||
for (const child of byManager.get(id) ?? []) total += 1 + countTotal(child.id, path);
|
||||
path.delete(id);
|
||||
totals.set(id, total);
|
||||
return total;
|
||||
}
|
||||
for (const e of employees) countTotal(e.id);
|
||||
for (const e of employees) countTotal(e.id, new Set());
|
||||
|
||||
return { childrenByManager: byManager, totalReportsById: totals, root: byManager.get("__root__") ?? [] };
|
||||
}, [employees]);
|
||||
|
||||
const matchIds = useMemo(() => {
|
||||
if (query.trim().length < 2) return null;
|
||||
// A focus target behaves exactly like a search hit — same ancestor
|
||||
// auto-expand, same ring, same fitView in graph mode — until the user
|
||||
// starts typing, at which point their own search takes over.
|
||||
if (query.trim().length < 2) return focusId ? new Set([focusId]) : null;
|
||||
const q = query.trim().toLowerCase();
|
||||
const matches = new Set<string>();
|
||||
for (const e of employees) {
|
||||
@@ -47,7 +66,14 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}, [query, employees]);
|
||||
}, [query, employees, focusId]);
|
||||
|
||||
// The chain above the focused person is auto-expanded, so the row only
|
||||
// exists after that render — scroll once it does.
|
||||
useEffect(() => {
|
||||
if (!focusId) return;
|
||||
focusRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}, [focusId, mode]);
|
||||
|
||||
const ancestorExpandIds = useMemo(() => {
|
||||
if (!matchIds) return null;
|
||||
@@ -55,7 +81,7 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
|
||||
const toExpand = new Set<string>();
|
||||
for (const id of matchIds) {
|
||||
let current = byId.get(id);
|
||||
while (current?.manager_id) {
|
||||
while (current?.manager_id && !toExpand.has(current.manager_id)) {
|
||||
toExpand.add(current.manager_id);
|
||||
current = byId.get(current.manager_id);
|
||||
}
|
||||
@@ -63,21 +89,42 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
|
||||
return toExpand;
|
||||
}, [matchIds, employees]);
|
||||
|
||||
function toggle(id: string) {
|
||||
// useCallback-stable: GraphOrgChart's layout memo depends on this
|
||||
// reference, so an unstable function would force a Dagre re-layout on
|
||||
// every unrelated re-render.
|
||||
const toggle = useCallback((id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
function isExpanded(id: string): boolean {
|
||||
return expanded.has(id) || (ancestorExpandIds?.has(id) ?? false);
|
||||
}
|
||||
const isExpanded = useCallback((id: string): boolean => expanded.has(id) || (ancestorExpandIds?.has(id) ?? false), [expanded, ancestorExpandIds]);
|
||||
|
||||
function renderNode(e: OrgEmployee, depth: number) {
|
||||
const children = childrenByManager.get(e.id) ?? [];
|
||||
const chartTree = useMemo<ChartNode[]>(() => {
|
||||
function toChartNode(e: OrgEmployee, ancestors: Set<string>): ChartNode {
|
||||
const children = ancestors.has(e.id) ? [] : (childrenByManager.get(e.id) ?? []);
|
||||
const total = totalReportsById.get(e.id) ?? 0;
|
||||
const nextAncestors = new Set(ancestors).add(e.id);
|
||||
return {
|
||||
id: e.id,
|
||||
kind: "person",
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
sublabel: e.job_title,
|
||||
href: `/employees/${e.id}`,
|
||||
avatar: { firstName: e.first_name, lastName: e.last_name },
|
||||
totalReports: total,
|
||||
matched: matchIds?.has(e.id) ?? false,
|
||||
children: children.map((c) => toChartNode(c, nextAncestors)),
|
||||
};
|
||||
}
|
||||
return root.map((r) => toChartNode(r, new Set()));
|
||||
}, [root, childrenByManager, totalReportsById, matchIds]);
|
||||
|
||||
function renderNode(e: OrgEmployee, depth: number, ancestors: Set<string>) {
|
||||
const children = ancestors.has(e.id) ? [] : (childrenByManager.get(e.id) ?? []);
|
||||
const hasChildren = children.length > 0;
|
||||
const expandedNow = isExpanded(e.id);
|
||||
const isMatch = matchIds?.has(e.id) ?? false;
|
||||
@@ -86,6 +133,7 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
|
||||
return (
|
||||
<div key={e.id}>
|
||||
<div
|
||||
ref={e.id === focusId ? focusRef : undefined}
|
||||
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-brand-100" : ""}`}
|
||||
style={{ paddingLeft: depth * 24 + 8 }}
|
||||
>
|
||||
@@ -109,7 +157,9 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && expandedNow && <div>{children.map((c) => renderNode(c, depth + 1))}</div>}
|
||||
{hasChildren && expandedNow && (
|
||||
<div>{children.map((c) => renderNode(c, depth + 1, new Set(ancestors).add(e.id)))}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,8 +190,13 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
|
||||
>
|
||||
Alles einklappen
|
||||
</button>
|
||||
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
|
||||
</div>
|
||||
<div>{root.map((r) => renderNode(r, 0))}</div>
|
||||
{mode === "list" ? (
|
||||
<div>{root.map((r) => renderNode(r, 0, new Set()))}</div>
|
||||
) : (
|
||||
<LazyGraphOrgChart tree={chartTree} isExpanded={isExpanded} onToggle={toggle} matchedIds={matchIds} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
139
components/orgchart/GraphOrgChart.tsx
Normal file
139
components/orgchart/GraphOrgChart.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
MiniMap,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
type Edge,
|
||||
} from "@xyflow/react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { collectVisible, layoutWithDagre } from "./graphLayout";
|
||||
import { OrgChartNode, type OrgChartRFNode } from "./OrgChartNode";
|
||||
import type { ChartNode, ChartNodeKind } from "./types";
|
||||
|
||||
// Referentially stable across renders — React Flow treats a new nodeTypes
|
||||
// object as a change and remounts every custom node otherwise.
|
||||
const NODE_TYPES = { orgNode: OrgChartNode };
|
||||
|
||||
const MINIMAP_COLORS: Record<ChartNodeKind, string> = {
|
||||
person: "#d6046e",
|
||||
role: "#5c2e91",
|
||||
group: "#c9b3c0",
|
||||
vacancy: "#f6cfe2",
|
||||
};
|
||||
|
||||
export type GraphOrgChartProps = {
|
||||
tree: ChartNode[];
|
||||
isExpanded: (id: string) => boolean;
|
||||
onToggle: (id: string) => void;
|
||||
matchedIds?: Set<string> | null;
|
||||
};
|
||||
|
||||
export function GraphOrgChart(props: GraphOrgChartProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<GraphOrgChartInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrgChartProps) {
|
||||
const { visibleNodes, visibleEdges } = useMemo(() => collectVisible(tree, isExpanded), [tree, isExpanded]);
|
||||
|
||||
const { rfNodes, rfEdges } = useMemo(() => {
|
||||
const positions = layoutWithDagre(visibleNodes, visibleEdges);
|
||||
const rfNodes: OrgChartRFNode[] = visibleNodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: "orgNode",
|
||||
position: positions.get(n.id) ?? { x: 0, y: 0 },
|
||||
draggable: false,
|
||||
data: {
|
||||
chartNode: n,
|
||||
expanded: n.children.length > 0 && isExpanded(n.id),
|
||||
hasChildren: n.children.length > 0,
|
||||
childCount: n.children.length,
|
||||
onToggle,
|
||||
},
|
||||
}));
|
||||
const rfEdges: Edge[] = visibleEdges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: "smoothstep",
|
||||
pathOptions: { borderRadius: 14 },
|
||||
style: { stroke: "#e3cddb", strokeWidth: 1.5 },
|
||||
}));
|
||||
return { rfNodes, rfEdges };
|
||||
}, [visibleNodes, visibleEdges, isExpanded, onToggle]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges);
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(rfNodes);
|
||||
setEdges(rfEdges);
|
||||
const raf = requestAnimationFrame(() => fitView({ padding: 0.2, duration: 300 }));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [rfNodes, rfEdges, setNodes, setEdges, fitView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchedIds || matchedIds.size === 0) return;
|
||||
const t = setTimeout(() => {
|
||||
const ids = [...matchedIds].filter((id) => visibleNodes.some((n) => n.id === id));
|
||||
if (ids.length > 0) fitView({ nodes: ids.map((id) => ({ id })), padding: 0.3, duration: 400 });
|
||||
}, 150);
|
||||
return () => clearTimeout(t);
|
||||
}, [matchedIds, visibleNodes, fitView]);
|
||||
|
||||
return (
|
||||
// dvh, not vh: on iOS/Android the browser chrome collapses on scroll, and
|
||||
// vh is measured against the *expanded* viewport — the canvas would hang
|
||||
// off the bottom of the screen for as long as the toolbar is showing.
|
||||
<div className="orgchart-canvas h-[70dvh] min-h-[420px] w-full overflow-hidden rounded-lg border border-border bg-white">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={NODE_TYPES}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
edgesFocusable={false}
|
||||
// Deliberately NOT elementsSelectable={false}: React Flow computes
|
||||
// `hasPointerEvents = isSelectable || isDraggable || onClick || …`
|
||||
// and sets pointer-events:none on the node wrapper when all of them
|
||||
// are off — which kills the expand button and the name link inside
|
||||
// the card. Selection stays on and is just styled away in CSS.
|
||||
minZoom={0.1}
|
||||
maxZoom={1.75}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1.4} color="#eedde6" />
|
||||
<Controls showInteractive={false} />
|
||||
{/* Hidden under lg via CSS: on a phone it would cover a real
|
||||
fraction of the canvas for little navigational benefit. */}
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
ariaLabel="Übersichtskarte"
|
||||
maskColor="rgba(249, 241, 245, 0.75)"
|
||||
nodeColor={(n) => MINIMAP_COLORS[(n.data as OrgChartNodeDataLike).chartNode.kind] ?? "#d6046e"}
|
||||
nodeStrokeWidth={0}
|
||||
nodeBorderRadius={3}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type OrgChartNodeDataLike = { chartNode: { kind: ChartNodeKind } };
|
||||
11
components/orgchart/LazyGraphOrgChart.tsx
Normal file
11
components/orgchart/LazyGraphOrgChart.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// React Flow + Dagre only ship to the client once someone actually switches
|
||||
// to "Grafisch" — everyone who stays on the (default) list view never loads
|
||||
// this bundle.
|
||||
export const LazyGraphOrgChart = dynamic(() => import("./GraphOrgChart").then((m) => m.GraphOrgChart), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-[70vh] min-h-[480px] items-center justify-center text-sm text-ink-muted">Lädt Grafik…</div>,
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { AsOfPicker } from "./AsOfPicker";
|
||||
import { EmployeeTree } from "./EmployeeTree";
|
||||
import { PositionTree } from "./PositionTree";
|
||||
import { ReorgWorkbench } from "./ReorgWorkbench";
|
||||
@@ -17,10 +18,29 @@ type OrgChartClientProps = {
|
||||
teams: OrgTeam[];
|
||||
openPositions: OpenPositionResolved[];
|
||||
reorgScenarios: ReorgScenarioSummary[];
|
||||
asOf: string;
|
||||
today: string;
|
||||
projectedCount: number;
|
||||
historyStartsAt: string | null;
|
||||
/** Arrived via "Im Organigramm anzeigen" — unfold and centre this person. */
|
||||
focusId: string | null;
|
||||
};
|
||||
|
||||
export function OrgChartClient({ employees, divisions, departments, teams, openPositions, reorgScenarios }: OrgChartClientProps) {
|
||||
export function OrgChartClient({
|
||||
employees,
|
||||
divisions,
|
||||
departments,
|
||||
teams,
|
||||
openPositions,
|
||||
reorgScenarios,
|
||||
asOf,
|
||||
today,
|
||||
projectedCount,
|
||||
historyStartsAt,
|
||||
focusId,
|
||||
}: OrgChartClientProps) {
|
||||
const [view, setView] = useState<View>("ma");
|
||||
const isToday = asOf === today;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -33,13 +53,31 @@ export function OrgChartClient({ employees, divisions, departments, teams, openP
|
||||
{ value: "reo", label: "Reorganisation" },
|
||||
]}
|
||||
/>
|
||||
{view === "ma" && <EmployeeTree employees={employees} />}
|
||||
|
||||
{view !== "reo" && (
|
||||
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />
|
||||
)}
|
||||
|
||||
{view === "ma" && <EmployeeTree employees={employees} focusId={focusId} />}
|
||||
{view === "pos" && (
|
||||
<PositionTree employees={employees} divisions={divisions} departments={departments} teams={teams} openPositions={openPositions} />
|
||||
)}
|
||||
{view === "reo" && (
|
||||
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
|
||||
)}
|
||||
{view === "reo" &&
|
||||
(isToday ? (
|
||||
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
|
||||
) : (
|
||||
// A reorg planned against a past or projected roster would be
|
||||
// applied to the *live* org anyway — better to send the user back
|
||||
// to today than to let them assemble moves from a roster that is
|
||||
// not the one the change would hit.
|
||||
<div className="rounded border border-border bg-white p-6 text-sm text-ink-body">
|
||||
<p className="font-semibold text-ink">Reorganisation nur zum heutigen Stand</p>
|
||||
<p className="mt-1 text-ink-muted">
|
||||
Es ist ein abweichender Stichtag gewählt. Reorganisationen wirken immer auf die aktuelle Struktur — wechseln
|
||||
Sie zurück auf „Heute“, um eine zu planen.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
122
components/orgchart/OrgChartNode.tsx
Normal file
122
components/orgchart/OrgChartNode.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import { Building2, ChevronDown, Plus, UserRound } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { memo } from "react";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { NODE_DIMENSIONS } from "./graphLayout";
|
||||
import type { ChartNode, ChartNodeKind } from "./types";
|
||||
|
||||
export type OrgChartNodeData = {
|
||||
chartNode: ChartNode;
|
||||
expanded: boolean;
|
||||
hasChildren: boolean;
|
||||
childCount: number;
|
||||
onToggle: (id: string) => void;
|
||||
};
|
||||
|
||||
export type OrgChartRFNode = Node<OrgChartNodeData, "orgNode">;
|
||||
|
||||
// One accent colour per node kind, carried by a left stripe. It is what makes
|
||||
// the four kinds separable at a glance when the chart is zoomed out far
|
||||
// enough that the text has stopped being legible.
|
||||
const KIND_ACCENT: Record<ChartNodeKind, string> = {
|
||||
person: "bg-brand-500",
|
||||
role: "bg-purple-text",
|
||||
group: "bg-ink-muted",
|
||||
vacancy: "bg-brand-200",
|
||||
};
|
||||
|
||||
const KIND_SHELL: Record<ChartNodeKind, string> = {
|
||||
person: "border-border bg-white",
|
||||
role: "border-border bg-white",
|
||||
group: "border-border-subtle bg-surface",
|
||||
vacancy: "border-dashed border-brand-200 bg-brand-50",
|
||||
};
|
||||
|
||||
// React Flow re-renders node components on every pan/zoom frame — memo is
|
||||
// required, not just tidy, to keep that smooth at a few hundred nodes.
|
||||
export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<OrgChartRFNode>) {
|
||||
const { chartNode, expanded, hasChildren, childCount, onToggle } = data;
|
||||
const { kind, label, sublabel, avatar, href, vacant, totalReports } = chartNode;
|
||||
const isMatch = chartNode.matched ?? false;
|
||||
const { width, height } = NODE_DIMENSIONS[kind];
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{kind === "person" && avatar ? (
|
||||
<Avatar firstName={avatar.firstName} lastName={avatar.lastName} size="sm" />
|
||||
) : kind === "vacancy" ? (
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed border-brand-200 text-brand-500">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : kind === "role" ? (
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-purple-bg text-purple-text">
|
||||
<UserRound className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-ink-muted">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span title={label} className="truncate text-[13px] font-bold leading-tight text-ink">
|
||||
{label}
|
||||
</span>
|
||||
{sublabel && (
|
||||
<span
|
||||
title={sublabel}
|
||||
className={`truncate text-[11px] leading-tight ${vacant ? "font-semibold text-warning-text" : "text-ink-muted"}`}
|
||||
>
|
||||
{sublabel}
|
||||
</span>
|
||||
)}
|
||||
{totalReports !== undefined && totalReports > 0 && (
|
||||
<span className="mt-0.5 text-[10px] font-semibold uppercase tracking-wide text-ink-muted">
|
||||
{childCount} direkt · {totalReports} gesamt
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width, height }}
|
||||
className={`group relative flex items-center overflow-visible rounded-lg border shadow-sm transition-shadow hover:shadow-md ${
|
||||
KIND_SHELL[kind]
|
||||
} ${isMatch ? "ring-2 ring-brand-500 ring-offset-1" : ""}`}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} className="!invisible" />
|
||||
|
||||
{/* Accent stripe, inset so it follows the card's rounded corner. */}
|
||||
<span className={`absolute inset-y-1.5 left-0 w-1 rounded-r ${KIND_ACCENT[kind]}`} />
|
||||
|
||||
{href ? (
|
||||
<Link href={href} className="nodrag nopan flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">{content}</div>
|
||||
)}
|
||||
|
||||
{/* Overhangs the bottom edge, sitting on the connector to its children —
|
||||
the conventional org-chart affordance, and a 28px touch target. */}
|
||||
{hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(id)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? `${label} zuklappen` : `${label} aufklappen (${childCount})`}
|
||||
className="nodrag nopan absolute -bottom-3.5 left-1/2 z-10 flex h-7 min-w-7 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-white px-1.5 text-[11px] font-bold text-ink-body shadow-sm transition-colors hover:border-brand-500 hover:bg-brand-500 hover:text-white"
|
||||
>
|
||||
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : childCount}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!invisible" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types";
|
||||
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
||||
import type { ChartNode, OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types";
|
||||
|
||||
type ViewMode = "list" | "graph";
|
||||
|
||||
function TreeRow({
|
||||
depth,
|
||||
@@ -43,38 +47,134 @@ type PositionTreeProps = {
|
||||
|
||||
export function PositionTree({ employees, divisions, departments, teams, openPositions }: PositionTreeProps) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set(["root"]));
|
||||
const [mode, setMode] = useState<ViewMode>("list");
|
||||
|
||||
function toggle(id: string) {
|
||||
// useCallback-stable: GraphOrgChart's layout memo depends on these
|
||||
// references, so unstable functions would force a Dagre re-layout on
|
||||
// every unrelated re-render.
|
||||
const toggle = useCallback((id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
|
||||
|
||||
const ceo = employees.find((e) => e.org_level === 0) ?? null;
|
||||
const divisionHeadByDivision = new Map<string, OrgEmployee>();
|
||||
const teamLeadByTeam = new Map<string, OrgEmployee>();
|
||||
const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>();
|
||||
for (const e of employees) {
|
||||
if (e.org_level === 1 && e.division_id) divisionHeadByDivision.set(e.division_id, e);
|
||||
if (e.is_lead && e.team_id) teamLeadByTeam.set(e.team_id, e);
|
||||
if (!e.is_lead && e.org_level === 3 && e.team_id) {
|
||||
if (!icsByTeamAndTitle.has(e.team_id)) icsByTeamAndTitle.set(e.team_id, new Map());
|
||||
const byTitle = icsByTeamAndTitle.get(e.team_id)!;
|
||||
if (!byTitle.has(e.job_title)) byTitle.set(e.job_title, []);
|
||||
byTitle.get(e.job_title)!.push(e);
|
||||
const { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle } = useMemo(() => {
|
||||
const divisionHeadByDivision = new Map<string, OrgEmployee>();
|
||||
const teamLeadByTeam = new Map<string, OrgEmployee>();
|
||||
const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>();
|
||||
for (const e of employees) {
|
||||
if (e.org_level === 1 && e.division_id) divisionHeadByDivision.set(e.division_id, e);
|
||||
if (e.is_lead && e.team_id) teamLeadByTeam.set(e.team_id, e);
|
||||
if (!e.is_lead && e.org_level === 3 && e.team_id) {
|
||||
if (!icsByTeamAndTitle.has(e.team_id)) icsByTeamAndTitle.set(e.team_id, new Map());
|
||||
const byTitle = icsByTeamAndTitle.get(e.team_id)!;
|
||||
if (!byTitle.has(e.job_title)) byTitle.set(e.job_title, []);
|
||||
byTitle.get(e.job_title)!.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
const openByTeam = new Map<string, OpenPositionResolved[]>();
|
||||
for (const p of openPositions) {
|
||||
if (!openByTeam.has(p.team_id)) openByTeam.set(p.team_id, []);
|
||||
openByTeam.get(p.team_id)!.push(p);
|
||||
}
|
||||
return { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle };
|
||||
}, [employees]);
|
||||
const openByTeam = useMemo(() => {
|
||||
const map = new Map<string, OpenPositionResolved[]>();
|
||||
for (const p of openPositions) {
|
||||
if (!map.has(p.team_id)) map.set(p.team_id, []);
|
||||
map.get(p.team_id)!.push(p);
|
||||
}
|
||||
return map;
|
||||
}, [openPositions]);
|
||||
|
||||
// Mirrors the JSX walk below into the generic ChartNode shape for graph
|
||||
// mode — same synthetic ids ("root", div-*, dept-*, team-*, teamKey-title)
|
||||
// the list already uses as `expanded` keys, so one Set drives both.
|
||||
const chartTree = useMemo<ChartNode[]>(() => {
|
||||
if (!ceo) return [];
|
||||
|
||||
function buildTeam(team: OrgTeam): ChartNode {
|
||||
const teamKey = `team-${team.id}`;
|
||||
const lead = teamLeadByTeam.get(team.id);
|
||||
const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map<string, OrgEmployee[]>();
|
||||
const openForTeam = openByTeam.get(team.id) ?? [];
|
||||
|
||||
const titleGroups: ChartNode[] = Array.from(icsByTitle.entries()).map(([title, people]) => ({
|
||||
id: `${teamKey}-${title}`,
|
||||
kind: "group",
|
||||
label: title,
|
||||
sublabel: `${people.length}x besetzt`,
|
||||
children: people.map((p) => ({
|
||||
id: p.id,
|
||||
kind: "person",
|
||||
label: `${p.first_name} ${p.last_name}`,
|
||||
href: `/employees/${p.id}`,
|
||||
avatar: { firstName: p.first_name, lastName: p.last_name },
|
||||
children: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
const vacancyNodes: ChartNode[] = openForTeam.map((p) => ({
|
||||
id: `vac-${p.id}`,
|
||||
kind: "vacancy",
|
||||
label: `${p.position_number} · ${p.title}`,
|
||||
href: "/positions",
|
||||
children: [],
|
||||
}));
|
||||
|
||||
return {
|
||||
id: teamKey,
|
||||
kind: "role",
|
||||
label: `Teamleitung ${team.name}`,
|
||||
sublabel: lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant",
|
||||
vacant: !lead,
|
||||
children: [...titleGroups, ...vacancyNodes],
|
||||
};
|
||||
}
|
||||
|
||||
function buildDept(dept: OrgDepartment): ChartNode {
|
||||
return {
|
||||
id: `dept-${dept.id}`,
|
||||
kind: "group",
|
||||
label: `${dept.org_number} · ${dept.name}`,
|
||||
children: teams.filter((t) => t.department_id === dept.id).map(buildTeam),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDivision(div: OrgDivision): ChartNode {
|
||||
const head = divisionHeadByDivision.get(div.id);
|
||||
return {
|
||||
id: `div-${div.id}`,
|
||||
kind: "role",
|
||||
label: `Bereichsleitung ${div.name}`,
|
||||
sublabel: head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant",
|
||||
vacant: !head,
|
||||
children: departments.filter((d) => d.division_id === div.id).map(buildDept),
|
||||
};
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: "root",
|
||||
kind: "role",
|
||||
label: "Geschäftsführung",
|
||||
sublabel: `besetzt: ${ceo.first_name} ${ceo.last_name}`,
|
||||
children: divisions.map(buildDivision),
|
||||
},
|
||||
];
|
||||
}, [ceo, divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle, openByTeam, divisions, departments, teams]);
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
|
||||
</div>
|
||||
{mode === "graph" ? (
|
||||
<LazyGraphOrgChart tree={chartTree} isExpanded={isExpanded} onToggle={toggle} />
|
||||
) : (
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
{ceo && (
|
||||
<TreeRow depth={0} expandable expandedNow={expanded.has("root")} onToggle={() => toggle("root")}>
|
||||
<span className="text-sm font-semibold text-ink">Geschäftsführung</span>
|
||||
@@ -168,6 +268,8 @@ export function PositionTree({ employees, divisions, departments, teams, openPos
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-bold text-ink">Neue Reorganisation</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-ink">Name der Reorganisation*</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||
|
||||
60
components/orgchart/graphLayout.ts
Normal file
60
components/orgchart/graphLayout.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import dagre from "@dagrejs/dagre";
|
||||
import type { ChartNode, ChartNodeKind } from "./types";
|
||||
|
||||
// Must match what OrgChartNode actually renders: Dagre reserves exactly this
|
||||
// much space per node, so a box that grows past it overlaps its neighbour.
|
||||
export const NODE_DIMENSIONS: Record<ChartNodeKind, { width: number; height: number }> = {
|
||||
person: { width: 268, height: 80 },
|
||||
role: { width: 268, height: 80 },
|
||||
group: { width: 244, height: 62 },
|
||||
vacancy: { width: 244, height: 62 },
|
||||
};
|
||||
|
||||
export type VisibleEdge = { id: string; source: string; target: string };
|
||||
|
||||
// Collapsing a node means excluding its descendants here, not CSS-hiding a
|
||||
// layout computed for the full tree — Dagre only ever lays out what's
|
||||
// actually visible, which is what keeps this usable at ~800 employees.
|
||||
export function collectVisible(tree: ChartNode[], isExpanded: (id: string) => boolean): { visibleNodes: ChartNode[]; visibleEdges: VisibleEdge[] } {
|
||||
const visibleNodes: ChartNode[] = [];
|
||||
const visibleEdges: VisibleEdge[] = [];
|
||||
|
||||
function walk(n: ChartNode) {
|
||||
visibleNodes.push(n);
|
||||
if (n.children.length > 0 && isExpanded(n.id)) {
|
||||
for (const c of n.children) {
|
||||
visibleEdges.push({ id: `${n.id}->${c.id}`, source: n.id, target: c.id });
|
||||
walk(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const root of tree) walk(root);
|
||||
|
||||
return { visibleNodes, visibleEdges };
|
||||
}
|
||||
|
||||
// Dagre returns center points; React Flow positions nodes by their top-left
|
||||
// corner, hence the width/height/2 offset below.
|
||||
export function layoutWithDagre(visibleNodes: ChartNode[], visibleEdges: VisibleEdge[]): Map<string, { x: number; y: number }> {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
// ranksep leaves room for the expand button that overhangs each node's
|
||||
// bottom edge; nodesep keeps sibling cards from visually merging.
|
||||
g.setGraph({ rankdir: "TB", nodesep: 40, ranksep: 88 });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
for (const n of visibleNodes) {
|
||||
const { width, height } = NODE_DIMENSIONS[n.kind];
|
||||
g.setNode(n.id, { width, height });
|
||||
}
|
||||
for (const e of visibleEdges) g.setEdge(e.source, e.target);
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
for (const n of visibleNodes) {
|
||||
const { width, height } = NODE_DIMENSIONS[n.kind];
|
||||
const { x, y } = g.node(n.id);
|
||||
positions.set(n.id, { x: x - width / 2, y: y - height / 2 });
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
@@ -15,3 +15,26 @@ export type OrgDivision = { id: string; org_number: string; name: string };
|
||||
export type OrgDepartment = { id: string; org_number: string; name: string; division_id: string };
|
||||
export type OrgTeam = { id: string; org_number: string; name: string; department_id: string };
|
||||
export type ReorgScenarioSummary = { id: string; name: string; effective_date: string; applied: boolean; applied_at: string | null };
|
||||
|
||||
// Generic tree shape both EmployeeTree and PositionTree map their own data
|
||||
// into for the graphical (React Flow + Dagre) view — see GraphOrgChart.
|
||||
// "role" = structural position (Geschäftsführung/Bereichsleitung/Teamleitung),
|
||||
// "group" = pure grouping label (Abteilung, Jobtitel-Gruppe), "vacancy" = open
|
||||
// position. EmployeeTree only ever produces "person" nodes.
|
||||
export type ChartNodeKind = "person" | "role" | "group" | "vacancy";
|
||||
|
||||
export type ChartNode = {
|
||||
id: string;
|
||||
kind: ChartNodeKind;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
href?: string;
|
||||
avatar?: { firstName: string; lastName: string };
|
||||
badge?: string;
|
||||
matched?: boolean;
|
||||
/** A structural role with nobody in it — drawn as an open slot. */
|
||||
vacant?: boolean;
|
||||
/** Reports below this node in total, not just direct ones. */
|
||||
totalReports?: number;
|
||||
children: ChartNode[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user