From ab50c21b8a7713d2a763920f9bdae1f9d68237d2 Mon Sep 17 00:00:00 2001 From: Andrei Laas Date: Mon, 28 Sep 2026 21:27:36 +0200 Subject: [PATCH] Klick auf eine Einheit zeigt ihre Angaben Die naheliegendste Geste in dieser Ansicht war ohne Wirkung: eine Karte im Organigramm liess sich anklicken und antwortete nicht. Jetzt oeffnet sie eine Leiste mit dem, was an der Einheit steht -- Orgnummer, Art, die Kette nach oben, Gueltigkeit, Leitung, Belegschaft, unbesetzte Planstellen, die direkt untergeordneten Einheiten und die Zahl der Personen im ganzen Teilbaum. Dazu dieselben zwei Handlungen wie auf der Karte, nur beschriftet statt als Symbol. Alles daraus stammt aus den Daten, die die Struktursicht ohnehin geladen hat. Kein zusaetzlicher Gang zur Datenbank und damit auch keine zweite Wahrheit -- was die Leiste zeigt, ist dasselbe, woraus der Baum daneben gezeichnet ist. Die Liste klickt sich genauso: beide Ansichten kommen aus einem Baum, und eine Handlung, die es nur in einer von beiden gibt, findet man in der anderen nie. org_units liefert dafuer zusaetzlich valid_from und valid_to, aber nur an die Struktursicht. Der Druck kommt ohne sie aus; auf OrgUnitNode stehen sie deshalb als optionale Felder und nicht als zwei weitere Spalten, die ueberall mitgeschleppt werden. --- app/(app)/orgchart/page.tsx | 8 +- components/orgchart/GraphOrgChart.tsx | 14 ++- components/orgchart/OrgChartNode.tsx | 16 ++- components/orgchart/OrgUnitPanel.tsx | 164 +++++++++++++++++++++++++ components/orgchart/PositionTree.tsx | 41 ++++++- components/orgchart/types.ts | 7 ++ tests/components/PositionTree.test.tsx | 16 ++- 7 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 components/orgchart/OrgUnitPanel.tsx diff --git a/app/(app)/orgchart/page.tsx b/app/(app)/orgchart/page.tsx index aa2d7bb..371c9b8 100644 --- a/app/(app)/orgchart/page.tsx +++ b/app/(app)/orgchart/page.tsx @@ -22,7 +22,13 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro const { org, units } = await withUser(await currentUserId(), async (tx) => { const [org, units] = await Promise.all([ loadOrgAsOf(tx, asOf), - tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(), + tx + .selectFrom("org_units") + // valid_from/valid_to nur hier: die Angaben zur Einheit zeigen sie, + // der Druck braucht sie nicht. + .select(["id", "org_number", "name", "parent_id", "unit_type", "valid_from", "valid_to"]) + .orderBy("org_number") + .execute(), ]); return { org, units }; }); diff --git a/components/orgchart/GraphOrgChart.tsx b/components/orgchart/GraphOrgChart.tsx index 14e4042..3b68d24 100644 --- a/components/orgchart/GraphOrgChart.tsx +++ b/components/orgchart/GraphOrgChart.tsx @@ -42,6 +42,7 @@ export type GraphOrgChartProps = { /** Nur die Struktursicht reicht sie herein; in der Mitarbeitersicht gibt es keine Einheiten. */ onAddUnit?: (unitId: string) => void; onDeleteUnit?: (unitId: string) => void; + onSelectUnit?: (unitId: string) => void; }; export function GraphOrgChart(props: GraphOrgChartProps) { @@ -52,7 +53,15 @@ export function GraphOrgChart(props: GraphOrgChartProps) { ); } -function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds, onAddUnit, onDeleteUnit }: GraphOrgChartProps) { +function GraphOrgChartInner({ + tree, + isExpanded, + onToggle, + matchedIds, + onAddUnit, + onDeleteUnit, + onSelectUnit, +}: GraphOrgChartProps) { const { visibleNodes, visibleEdges } = useMemo(() => collectVisible(tree, isExpanded), [tree, isExpanded]); const { rfNodes, rfEdges } = useMemo(() => { @@ -70,6 +79,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds, onAddUnit, onToggle, onAddUnit, onDeleteUnit, + onSelectUnit, }, })); const rfEdges: Edge[] = visibleEdges.map((e) => ({ @@ -81,7 +91,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds, onAddUnit, style: { stroke: "#e3cddb", strokeWidth: 1.5 }, })); return { rfNodes, rfEdges }; - }, [visibleNodes, visibleEdges, isExpanded, onToggle, onAddUnit, onDeleteUnit]); + }, [visibleNodes, visibleEdges, isExpanded, onToggle, onAddUnit, onDeleteUnit, onSelectUnit]); const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges); diff --git a/components/orgchart/OrgChartNode.tsx b/components/orgchart/OrgChartNode.tsx index 8e33adb..c77f72d 100644 --- a/components/orgchart/OrgChartNode.tsx +++ b/components/orgchart/OrgChartNode.tsx @@ -17,6 +17,8 @@ export type OrgChartNodeData = { /** Nur in der Struktursicht gesetzt — ohne sie bleibt die Karte unverändert. */ onAddUnit?: (unitId: string) => void; onDeleteUnit?: (unitId: string) => void; + /** Klick auf die Karte einer Einheit — öffnet ihre Angaben. */ + onSelectUnit?: (unitId: string) => void; }; export type OrgChartRFNode = Node; @@ -45,7 +47,7 @@ const KIND_SHELL: Record = { // 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) { - const { chartNode, expanded, hasChildren, childCount, onToggle, onAddUnit, onDeleteUnit } = data; + const { chartNode, expanded, hasChildren, childCount, onToggle, onAddUnit, onDeleteUnit, onSelectUnit } = data; const { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor, unitId } = chartNode; const isMatch = chartNode.matched ?? false; const { width, height } = NODE_DIMENSIONS[kind]; @@ -161,6 +163,18 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps {content} + ) : unitId && onSelectUnit ? ( + // Die ganze Karte, nicht ein Symbol darin: bei achtzig Einheiten ist + // die Fläche das Ziel, das man trifft, ohne hinzusehen. + ) : (
{content}
)} diff --git a/components/orgchart/OrgUnitPanel.tsx b/components/orgchart/OrgUnitPanel.tsx new file mode 100644 index 0000000..544c1a9 --- /dev/null +++ b/components/orgchart/OrgUnitPanel.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { Plus, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/Button"; +import { SlideOver } from "@/components/ui/SlideOver"; +import { fmtDate, fmtName } from "@/lib/format"; +import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "./types"; + +// Was an einer Organisationseinheit steht — beim Klick auf ihre Karte. +// +// Alles hier kommt aus den Daten, die die Struktursicht ohnehin geladen hat: +// Einheiten, Personen und Vakanzen zum eingestellten Stichtag. Kein zusätzlicher +// Gang zur Datenbank, und damit auch keine zweite Wahrheit — was die Karte +// zeigt, ist dasselbe, woraus der Baum daneben gezeichnet ist. + +function Zeile({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +export function OrgUnitPanel({ + unit, + units, + employees, + vacancies, + onAddUnit, + onDeleteUnit, + onClose, +}: { + unit: OrgUnitNode; + units: OrgUnitNode[]; + employees: OrgEmployee[]; + vacancies: OrgVacancy[]; + onAddUnit: (unitId: string) => void; + onDeleteUnit: (unitId: string) => void; + onClose: () => void; +}) { + const kinder = units.filter((u) => u.parent_id === unit.id); + + // Die Kette nach oben, mit Schutz gegen einen Kreis in parent_id. Der ist + // heute nicht herstellbar, aber diese Schleife wäre die erste Stelle, die + // ihn ausbaden müsste — und ein hängender Browser sagt niemandem, woran es + // liegt. + const kette: OrgUnitNode[] = []; + const gesehen = new Set(); + let lauf = unit.parent_id ? units.find((u) => u.id === unit.parent_id) : undefined; + while (lauf && !gesehen.has(lauf.id)) { + gesehen.add(lauf.id); + kette.unshift(lauf); + lauf = lauf.parent_id ? units.find((u) => u.id === lauf!.parent_id) : undefined; + } + + const eigene = employees.filter((e) => e.org_unit_id === unit.id); + const leitung = eigene.find((e) => e.is_chief); + const belegschaft = eigene.filter((e) => !e.is_chief); + const offene = vacancies.filter((v) => v.org_unit_id === unit.id); + + // Alles darunter, nicht nur die direkten Kinder — die Frage hinter „wie + // gross ist dieser Bereich" meint immer den ganzen Teilbaum. + const teilbaum = new Set([unit.id]); + for (let gewachsen = true; gewachsen; ) { + gewachsen = false; + for (const u of units) { + if (u.parent_id && teilbaum.has(u.parent_id) && !teilbaum.has(u.id)) { + teilbaum.add(u.id); + gewachsen = true; + } + } + } + const imTeilbaum = employees.filter((e) => teilbaum.has(e.org_unit_id)).length; + + // Dieselbe Bedingung wie am Papierkorb auf der Karte: nur ein Blatt ohne + // Planstellen lässt sich entfernen. Die Datenbank prüft strenger — sie sieht + // auch geschlossene Planstellen, die hier nicht gezeichnet sind. + const entfernbar = kinder.length === 0 && eigene.length === 0 && offene.length === 0 && unit.parent_id !== null; + + return ( + +
+
+ {unit.org_number} + {unit.unit_type} + + {kette.length > 0 ? kette.map((u) => u.name).join(" › ") : "—"} + + {unit.valid_from ? fmtDate(unit.valid_from) : "—"} + {unit.valid_to ? fmtDate(unit.valid_to) : "unbefristet"} +
+ +
+

Besetzung

+ + {leitung ? ( + + {fmtName(leitung.first_name, leitung.last_name)} + + ) : ( + vakant + )} + + {belegschaft.length} + {offene.length} +
+ +
+

Struktur

+ {kinder.length} + {imTeilbaum} +
+ + {kinder.length > 0 && ( +
+

Direkt darunter

+
    + {kinder.map((k) => ( +
  • + {k.org_number} · {k.name}{" "} + ({k.unit_type}) +
  • + ))} +
+
+ )} + + {offene.length > 0 && ( +
+

Unbesetzt

+
    + {offene.map((v) => ( +
  • + {v.position_number} · {v.job_title} + {v.is_chief && (Leitung)} +
  • + ))} +
+ {/* Kontierung, Tätigkeit und Gültigkeit einer Planstelle stehen auf + „Positionen"; von hier führt wenigstens der Weg dorthin. */} + + Auf Positionen bearbeiten + +
+ )} + +
+ + {entfernbar && ( + + )} +
+
+
+ ); +} diff --git a/components/orgchart/PositionTree.tsx b/components/orgchart/PositionTree.tsx index 79bce98..bbbeee8 100644 --- a/components/orgchart/PositionTree.tsx +++ b/components/orgchart/PositionTree.tsx @@ -9,6 +9,7 @@ import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { useToast } from "@/components/ui/Toast"; import { CreateOrgUnitModal } from "./CreateOrgUnitModal"; import { LazyGraphOrgChart } from "./LazyGraphOrgChart"; +import { OrgUnitPanel } from "./OrgUnitPanel"; import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types"; import { fmtName } from "@/lib/format"; @@ -54,15 +55,24 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps) const onAddUnit = useCallback((unitId: string) => setAnlegenUnter(unitId), []); const eltern = anlegenUnter ? (units.find((u) => u.id === anlegenUnter) ?? null) : null; + // Die Einheit, deren Angaben offen sind. Der Klick auf die Karte war bisher + // ohne Wirkung — bei achtzig Einheiten die naheliegendste Geste ohne Antwort. + const [gewaehlt, setGewaehlt] = useState(null); + const onSelectUnit = useCallback((unitId: string) => setGewaehlt(unitId), []); + const einheit = gewaehlt ? (units.find((u) => u.id === gewaehlt) ?? null) : null; + // Ohne Rückfrage, wie beim Entfernen einer Planstelle: entfernbar ist nur, // was nie eine Planstelle getragen hat und nichts unter sich hat. Alles // andere weist die Datenbank mit dem Grund ab, und der steht dann im Toast. const onDeleteUnit = useCallback( async (unitId: string) => { - const einheit = units.find((u) => u.id === unitId); + const betroffen = units.find((u) => u.id === unitId); const result = await deleteOrgUnit(unitId); if (result.success) { - showToast(`${einheit ? `${einheit.org_number} · ${einheit.name}` : "Einheit"} entfernt.`); + showToast(`${betroffen ? `${betroffen.org_number} · ${betroffen.name}` : "Einheit"} entfernt.`); + // Die Angaben zu einer Einheit, die es nicht mehr gibt, stünden sonst + // offen da. + setGewaehlt(null); router.refresh(); } else { showToast(result.error ?? "Fehler beim Entfernen.", "error"); @@ -92,6 +102,7 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps) onToggle={toggle} onAddUnit={onAddUnit} onDeleteUnit={onDeleteUnit} + onSelectUnit={onSelectUnit} /> ) : (
@@ -104,12 +115,25 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps) onToggle={toggle} onAddUnit={onAddUnit} onDeleteUnit={onDeleteUnit} + onSelectUnit={onSelectUnit} /> ))}
)} {eltern && setAnlegenUnter(null)} />} + + {einheit && ( + setGewaehlt(null)} + /> + )} ); } @@ -121,6 +145,7 @@ function ListNode({ onToggle, onAddUnit, onDeleteUnit, + onSelectUnit, }: { node: ChartNode; depth: number; @@ -128,6 +153,7 @@ function ListNode({ onToggle: (id: string) => void; onAddUnit: (unitId: string) => void; onDeleteUnit: (unitId: string) => void; + onSelectUnit: (unitId: string) => void; }) { const expandable = node.children.length > 0; const open = expanded.has(node.id); @@ -158,6 +184,16 @@ function ListNode({ > {node.label} + ) : node.unitId ? ( + ) : ( {node.label} )} @@ -204,6 +240,7 @@ function ListNode({ onToggle={onToggle} onAddUnit={onAddUnit} onDeleteUnit={onDeleteUnit} + onSelectUnit={onSelectUnit} /> ))} diff --git a/components/orgchart/types.ts b/components/orgchart/types.ts index ae67d95..1a8fc6f 100644 --- a/components/orgchart/types.ts +++ b/components/orgchart/types.ts @@ -38,6 +38,13 @@ export type OrgUnitNode = { name: string; parent_id: string | null; unit_type: "Gesellschaft" | "Bereich" | "Abteilung" | "Team"; + /** + * Nur die Struktursicht liest sie, für die Angaben zur Einheit. Der Druck + * kommt ohne sie aus und lädt sie deshalb auch nicht — optional statt + * überall mitgeschleppt. + */ + valid_from?: string; + valid_to?: string | null; }; // Generic tree shape both EmployeeTree and PositionTree map their own data diff --git a/tests/components/PositionTree.test.tsx b/tests/components/PositionTree.test.tsx index 1e7e084..945228f 100644 --- a/tests/components/PositionTree.test.tsx +++ b/tests/components/PositionTree.test.tsx @@ -1,4 +1,5 @@ -import { render as rtlRender, screen } from "@testing-library/react"; +import { render as rtlRender, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { buildUnitTree, PositionTree } from "@/components/orgchart/PositionTree"; import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "@/components/orgchart/types"; @@ -90,4 +91,17 @@ describe("PositionTree", () => { render(); expect(screen.getByRole("button", { name: /Einheit .*OE-0411.* entfernen/ })).toBeInTheDocument(); }); + + it("öffnet beim Klick auf eine Einheit deren Angaben", async () => { + // Der Klick auf die Karte war lange ohne Wirkung — die naheliegendste + // Geste in dieser Ansicht, und sie antwortete nicht. + render(); + await userEvent.click(screen.getByRole("button", { name: /Angaben zu .*Produktion/ })); + + const panel = await screen.findByRole("dialog"); + expect(within(panel).getByText("OE-0010 · Bereich")).toBeInTheDocument(); + // Eine Person, keine Leitung, eine offene Planstelle — genau der Bestand + // aus den Fixtures. + expect(within(panel).getByText("vakant")).toBeInTheDocument(); + }); });