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(); + }); });