From a6b6a7d67c5dc261a066268f32783f81f93d4ebb Mon Sep 17 00:00:00 2001 From: Andrei Laas Date: Mon, 28 Sep 2026 16:28:33 +0200 Subject: [PATCH] Eine Organisationseinheit aus dem Organigramm heraus anlegen Bisher gab es dafuer nur den Import. Jetzt sitzt auf jeder Einheit im Organigramm -- in der Grafik wie in der Liste -- ein Plus, das eine untergeordnete Einheit anlegt, wahlweise gleich mit Leitungsplanstelle. Bewusst nur dieser eine Fall. Umbenennen, verschieben und schliessen fehlen nicht aus Zeitmangel: org_units.parent_id traegt kein Datum, ein Verschieben aenderte damit auch jede Auswertung auf einen vergangenen Stichtag, und der fruehere Stand waere danach nirgends mehr ablesbar. Anlegen stellt diese Frage nicht, weil vorher nichts da war -- und es kann dabei auch kein Kreis entstehen, was hier mehr wiegt als es klingt: die rekursive Abfrage in om_reporting_lines hat weder Tiefenbegrenzung noch Kreiserkennung. Die Leitungsplanstelle entsteht ueber create_position statt durch eine zweite Fassung derselben Logik -- dort haengen Jobkatalog, Nummernvergabe und die Pruefung "je Einheit genau eine Leitung". Die Orgnummer wird eingetragen, nicht vergeben. Vorgeschlagen wird die naechste freie Nummer der bestehenden Reihe, und nur dann, wenn sich im Bestand genau eine Systematik ablesen laesst -- eine plausibel aussehende, aber erfundene Nummer prueft niemand nach. Das Datum kommt nicht aus dem Stichtag der Ansicht. Wer sich die Struktur zum letzten Jahresende ansieht und auf Plus drueckt, will in aller Regel eine Einheit von heute anlegen. --- actions/org.ts | 31 ++++ components/orgchart/CreateOrgUnitModal.tsx | 126 ++++++++++++++ components/orgchart/GraphOrgChart.tsx | 7 +- components/orgchart/OrgChartNode.tsx | 27 ++- components/orgchart/PositionTree.tsx | 42 ++++- components/orgchart/types.ts | 8 + ...929120000_organisationseinheit_anlegen.sql | 156 ++++++++++++++++++ lib/org-nummer.ts | 46 ++++++ lib/types.ts | 4 + tests/components/PositionTree.test.tsx | 71 ++++++++ tests/unit/org-nummer.test.ts | 38 +++++ 11 files changed, 547 insertions(+), 9 deletions(-) create mode 100644 actions/org.ts create mode 100644 components/orgchart/CreateOrgUnitModal.tsx create mode 100644 db/migrations/20260929120000_organisationseinheit_anlegen.sql create mode 100644 lib/org-nummer.ts create mode 100644 tests/components/PositionTree.test.tsx create mode 100644 tests/unit/org-nummer.test.ts diff --git a/actions/org.ts b/actions/org.ts new file mode 100644 index 0000000..471afbe --- /dev/null +++ b/actions/org.ts @@ -0,0 +1,31 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { currentUserId } from "@/lib/auth/session"; +import { runMutation, type ActionResult } from "@/lib/db/rpc"; + +/** + * Eine Organisationseinheit unter einer bestehenden anlegen. + * + * Bewusst die einzige Änderung an der Struktur, die es aus der Anwendung + * heraus gibt. Umbenennen, verschieben und schliessen fehlen nicht aus + * Zeitmangel: org_units.parent_id trägt kein Datum, ein Verschieben änderte + * damit auch jede Auswertung auf einen vergangenen Stichtag. Siehe Migration + * 20260929120000. + */ +export async function createOrgUnit(payload: { + parent_id: string; + org_number: string; + name: string; + unit_type: "Bereich" | "Abteilung" | "Team"; + valid_from: string; + /** Leer lassen heisst: die Einheit entsteht ohne Leitung. */ + leitung_taetigkeit?: string; +}): Promise { + const result = await runMutation(await currentUserId(), "create_org_unit", payload); + if (!result.success) return result; + // Die Einheit taucht in drei Ansichten auf, und in allen dreien wäre sie + // sonst erst nach dem nächsten harten Neuladen zu sehen. + for (const path of ["/orgchart", "/positions", "/"]) revalidatePath(path); + return result; +} diff --git a/components/orgchart/CreateOrgUnitModal.tsx b/components/orgchart/CreateOrgUnitModal.tsx new file mode 100644 index 0000000..ec9e24a --- /dev/null +++ b/components/orgchart/CreateOrgUnitModal.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { createOrgUnit } from "@/actions/org"; +import { Button } from "@/components/ui/Button"; +import { SelectField, TextField } from "@/components/ui/Field"; +import { Modal } from "@/components/ui/Modal"; +import { useToast } from "@/components/ui/Toast"; +import { todayIso } from "@/lib/format"; +import { naechsteOrgnummer } from "@/lib/org-nummer"; +import type { OrgUnitNode } from "./types"; + +type Art = "Bereich" | "Abteilung" | "Team"; + +// „Gesellschaft“ fehlt in der Auswahl mit Absicht: es gibt genau eine, sie ist +// die Wurzel, und unter einer anderen Einheit wäre sie ein Etikett, das seiner +// Stelle im Baum widerspricht. Die Datenbankfunktion weist es ohnehin ab — die +// fehlende Zeile erspart den Weg dorthin. +const ARTEN: { value: Art; label: string }[] = [ + { value: "Bereich", label: "Bereich" }, + { value: "Abteilung", label: "Abteilung" }, + { value: "Team", label: "Team" }, +]; + +/** + * Die Art, die unter einer Einheit am wahrscheinlichsten ist: eine Stufe + * feiner als die darüber. Nur eine Vorbelegung — die Tiefe des Baums ist frei, + * und eine Abteilung unter einer Abteilung ist erlaubt. + */ +function vorschlagArt(eltern: OrgUnitNode["unit_type"]): Art { + if (eltern === "Gesellschaft") return "Bereich"; + if (eltern === "Bereich") return "Abteilung"; + return "Team"; +} + +export function CreateOrgUnitModal({ + parent, + units, + onClose, +}: { + parent: OrgUnitNode; + units: OrgUnitNode[]; + onClose: () => void; +}) { + const { showToast } = useToast(); + const router = useRouter(); + const [orgNumber, setOrgNumber] = useState(() => naechsteOrgnummer(units.map((u) => u.org_number)) ?? ""); + const [name, setName] = useState(""); + const [art, setArt] = useState(() => vorschlagArt(parent.unit_type)); + // **Nicht** der Stichtag der Ansicht. Wer sich das Organigramm zum letzten + // Jahresende ansieht und hier auf Plus drückt, will in aller Regel eine + // Einheit von heute anlegen und keine rückdatierte. Ein stillschweigend + // übernommenes Datum aus der Ansicht wäre die Art von Vorbelegung, die + // niemand liest und die hinterher niemand erklären kann. + const [validFrom, setValidFrom] = useState(todayIso); + const [leitung, setLeitung] = useState(""); + const [pending, setPending] = useState(false); + + async function handleSubmit() { + if (!orgNumber.trim() || !name.trim() || !validFrom) { + showToast("Orgnummer, Bezeichnung und Gültigkeitsbeginn sind Pflicht.", "error"); + return; + } + setPending(true); + const result = await createOrgUnit({ + parent_id: parent.id, + org_number: orgNumber.trim(), + name: name.trim(), + unit_type: art, + valid_from: validFrom, + leitung_taetigkeit: leitung.trim() || undefined, + }); + setPending(false); + if (result.success) { + showToast(leitung.trim() ? "Einheit samt Leitungsplanstelle angelegt." : "Einheit angelegt."); + router.refresh(); + onClose(); + } else { + showToast(result.error ?? "Fehler beim Anlegen.", "error"); + } + } + + return ( + + + + + } + > +
+

+ Unter {parent.org_number} · {parent.name}. + Bestehende Einheiten lassen sich nicht hierher verschieben — die neue entsteht leer. +

+ + + + setArt(v as Art)} options={ARTEN} /> + + + +
+
+ ); +} diff --git a/components/orgchart/GraphOrgChart.tsx b/components/orgchart/GraphOrgChart.tsx index 3d30824..d9cf742 100644 --- a/components/orgchart/GraphOrgChart.tsx +++ b/components/orgchart/GraphOrgChart.tsx @@ -39,6 +39,8 @@ export type GraphOrgChartProps = { isExpanded: (id: string) => boolean; onToggle: (id: string) => void; matchedIds?: Set | null; + /** Nur die Struktursicht reicht sie herein; in der Mitarbeitersicht gibt es keine Einheiten. */ + onAddUnit?: (unitId: string) => void; }; export function GraphOrgChart(props: GraphOrgChartProps) { @@ -49,7 +51,7 @@ export function GraphOrgChart(props: GraphOrgChartProps) { ); } -function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrgChartProps) { +function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds, onAddUnit }: GraphOrgChartProps) { const { visibleNodes, visibleEdges } = useMemo(() => collectVisible(tree, isExpanded), [tree, isExpanded]); const { rfNodes, rfEdges } = useMemo(() => { @@ -65,6 +67,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg hasChildren: n.children.length > 0, childCount: n.children.length, onToggle, + onAddUnit, }, })); const rfEdges: Edge[] = visibleEdges.map((e) => ({ @@ -76,7 +79,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg style: { stroke: "#e3cddb", strokeWidth: 1.5 }, })); return { rfNodes, rfEdges }; - }, [visibleNodes, visibleEdges, isExpanded, onToggle]); + }, [visibleNodes, visibleEdges, isExpanded, onToggle, onAddUnit]); 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 3f1a0e4..206436a 100644 --- a/components/orgchart/OrgChartNode.tsx +++ b/components/orgchart/OrgChartNode.tsx @@ -14,6 +14,8 @@ export type OrgChartNodeData = { hasChildren: boolean; childCount: number; onToggle: (id: string) => void; + /** Nur in der Struktursicht gesetzt — ohne sie bleibt die Karte unverändert. */ + onAddUnit?: (unitId: string) => void; }; export type OrgChartRFNode = Node; @@ -42,8 +44,8 @@ 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 } = data; - const { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor } = chartNode; + const { chartNode, expanded, hasChildren, childCount, onToggle, onAddUnit } = 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]; @@ -107,6 +109,27 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps + {/* Erscheint erst beim Überfahren der Karte: auf achtzig Einheiten wären + achtzig ständig sichtbare Pluszeichen ein Muster und kein Angebot. + Auf Geräten ohne Zeigegerät bleibt er stehen — dort gibt es kein + Überfahren, und unsichtbar hiesse dann unerreichbar. */} + {unitId && onAddUnit && ( + + )} + {/* Accent stripe, inset so it follows the card's rounded corner. */} diff --git a/components/orgchart/PositionTree.tsx b/components/orgchart/PositionTree.tsx index 15c8745..1733f7d 100644 --- a/components/orgchart/PositionTree.tsx +++ b/components/orgchart/PositionTree.tsx @@ -1,9 +1,10 @@ "use client"; -import { ChevronDown, ChevronRight } from "lucide-react"; +import { ChevronDown, ChevronRight, Plus } from "lucide-react"; import Link from "next/link"; import { useCallback, useMemo, useState } from "react"; import { SegmentedControl } from "@/components/ui/SegmentedControl"; +import { CreateOrgUnitModal } from "./CreateOrgUnitModal"; import { LazyGraphOrgChart } from "./LazyGraphOrgChart"; import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types"; import { fmtName } from "@/lib/format"; @@ -41,6 +42,13 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps) }, []); const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]); + // Die Einheit, unter der gerade eine neue entstehen soll. Als Kennung und + // nicht als Objekt: nach dem Anlegen lädt die Seite neu, und ein + // festgehaltenes Objekt wäre dann der Stand von vorhin. + const [anlegenUnter, setAnlegenUnter] = useState(null); + const onAddUnit = useCallback((unitId: string) => setAnlegenUnter(unitId), []); + const eltern = anlegenUnter ? (units.find((u) => u.id === anlegenUnter) ?? null) : null; + const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]); return ( @@ -56,14 +64,16 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps) /> {mode === "graph" ? ( - + ) : (
{tree.map((node) => ( - + ))}
)} + + {eltern && setAnlegenUnter(null)} />} ); } @@ -73,11 +83,13 @@ function ListNode({ depth, expanded, onToggle, + onAddUnit, }: { node: ChartNode; depth: number; expanded: Set; onToggle: (id: string) => void; + onAddUnit: (unitId: string) => void; }) { const expandable = node.children.length > 0; const open = expanded.has(node.id); @@ -85,7 +97,7 @@ function ListNode({ return (
{expandable ? ( @@ -114,8 +126,27 @@ function ListNode({ {node.sublabel && ( {node.sublabel} )} + {/* Derselbe Knopf wie auf der Karte in der Grafik — beide Ansichten + kommen aus einem Baum, und eine Handlung, die es nur in einer von + beiden gibt, findet man in der anderen nie. */} + {node.unitId && ( + + )}
- {open && node.children.map((child) => )} + {open && + node.children.map((child) => ( + + ))}
); } @@ -203,6 +234,7 @@ export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], va return { id: key, kind: "role", + unitId: unit.id, label: `${unit.org_number} · ${unit.name}`, // Die Gesellschaft bleibt ohne Vermerk: sie wird nicht geführt, sondern // ist das Ganze. „Leitung vakant" las sich dort wie eine offene Stelle, diff --git a/components/orgchart/types.ts b/components/orgchart/types.ts index 3cf7f9e..ae67d95 100644 --- a/components/orgchart/types.ts +++ b/components/orgchart/types.ts @@ -66,5 +66,13 @@ export type ChartNode = { coveringFor?: string; /** Reports below this node in total, not just direct ones. */ totalReports?: number; + /** + * Die Organisationseinheit hinter dem Knoten — gesetzt nur in der + * Struktursicht und nur an Einheiten, nicht an Personen, Tätigkeitsgruppen + * oder Vakanzen. Daran hängt der Knopf „Einheit darunter anlegen“: aus der + * Knoten-ID (`unit-`) liesse sich dasselbe herauslösen, aber dann + * wäre die ID plötzlich ein Datenformat und nicht mehr nur ein Schlüssel. + */ + unitId?: string; children: ChartNode[]; }; diff --git a/db/migrations/20260929120000_organisationseinheit_anlegen.sql b/db/migrations/20260929120000_organisationseinheit_anlegen.sql new file mode 100644 index 0000000..344ad2e --- /dev/null +++ b/db/migrations/20260929120000_organisationseinheit_anlegen.sql @@ -0,0 +1,156 @@ +-- Eine Organisationseinheit aus der Anwendung heraus anlegen +-- +-- Bisher gab es dafür nur den Import — und für alles andere an einer Einheit +-- (umbenennen, verschieben, schliessen) gibt es weiterhin nur die Datenbank. +-- Das hier ist bewusst nur der eine Fall: **eine neue Einheit unter einer +-- bestehenden**. +-- +-- Warum gerade der: er ist der einzige, der nichts umschreibt. Die neue +-- Einheit ist immer ein Blatt, also +-- +-- * kann kein Kreis entstehen — und das ist keine Kleinigkeit: die +-- rekursive Abfrage `ancestry` in om_reporting_lines hat weder +-- Tiefenbegrenzung noch Kreiserkennung, ein Kreis in parent_id würde dort +-- nicht zu einem falschen Ergebnis führen, sondern zu keinem; +-- * wird keine Vergangenheit verändert. org_units.parent_id trägt kein +-- Datum: wer eine bestehende Einheit umhängt, ändert damit auch jede +-- Auswertung auf einen vergangenen Stichtag, und der frühere Stand ist +-- danach nirgends mehr ablesbar. Beim Anlegen stellt sich die Frage +-- nicht, weil vorher nichts da war. +-- +-- Das Verschieben bestehender Einheiten braucht deshalb eine Entscheidung +-- (Vergangenheit überschreiben oder die Aufhängung datieren) und steht +-- absichtlich noch nicht hier. + +create or replace function create_org_unit(payload jsonb) +returns uuid language plpgsql +set search_path = public, pg_temp +as $$ +declare + v_parent_id uuid := (payload->>'parent_id')::uuid; + v_org_number text := nullif(trim(payload->>'org_number'), ''); + v_name text := nullif(trim(payload->>'name'), ''); + v_unit_type text := nullif(trim(payload->>'unit_type'), ''); + v_valid_from date := coalesce(nullif(payload->>'valid_from','')::date, current_date); + v_leitung text := nullif(trim(payload->>'leitung_taetigkeit'), ''); + v_parent record; + v_id uuid; +begin + perform require_hr_admin(); + + if v_parent_id is null then + -- Eine Einheit ohne Übergeordnete wäre eine zweite Wurzel. Es gibt genau + -- eine, und sie entsteht nicht hier. + raise exception 'Es muss eine übergeordnete Einheit angegeben werden.'; + end if; + + select id, org_number, name, valid_from, valid_to + into v_parent + from org_units + where id = v_parent_id; + if v_parent.id is null then + raise exception 'Die übergeordnete Einheit existiert nicht.'; + end if; + + if v_org_number is null then + raise exception 'Es muss eine Orgnummer angegeben werden.'; + end if; + if v_name is null then + raise exception 'Es muss eine Bezeichnung angegeben werden.'; + end if; + if v_unit_type is null then + raise exception 'Es muss eine Art angegeben werden.'; + end if; + + -- Die Gesellschaft ist das Ganze und steht über allem. Als Kind einer + -- anderen Einheit wäre sie ein Etikett, das seiner Stelle im Baum + -- widerspricht — und genau daran ist in diesem Projekt schon dreimal Code + -- zerbrochen, der aus dem Etikett auf die Ebene geschlossen hat. + if v_unit_type = 'Gesellschaft' then + raise exception 'Die Gesellschaft ist die Wurzel und kann nicht unter einer anderen Einheit stehen.'; + end if; + + -- Der eindeutige Index würde das ebenfalls abfangen, aber mit einer + -- Meldung, die in der Oberfläche nichts erklärt. + if exists (select 1 from org_units where org_number = v_org_number) then + raise exception 'Die Orgnummer % ist bereits vergeben.', v_org_number; + end if; + + -- Eine Einheit, die früher beginnt als die über ihr, hängt an einem Tag im + -- Baum, an dem es diesen Baum noch nicht gab. Auf einen solchen Stichtag + -- ausgewertet stünde sie ohne Vorfahren da. + if v_valid_from < v_parent.valid_from then + raise exception 'Die übergeordnete Einheit gilt erst ab %. Ein früherer Beginn ist nicht möglich.', v_parent.valid_from; + end if; + if v_parent.valid_to is not null and v_valid_from >= v_parent.valid_to then + raise exception 'Die übergeordnete Einheit gilt nur bis %.', v_parent.valid_to; + end if; + + insert into org_units (org_number, name, parent_id, unit_type, valid_from) + values (v_org_number, v_name, v_parent_id, v_unit_type::org_unit_type, v_valid_from) + returning id into v_id; + + insert into audit_log (actor_user_id, actor_name, action, target_label, details) + values (app_current_user_id(), current_actor_name(), 'Organisationseinheit angelegt', + v_org_number || ' · ' || v_name, + 'Unter ' || v_parent.org_number || ' · ' || v_parent.name || + ', Art ' || v_unit_type || ', gültig ab ' || v_valid_from || + case when v_leitung is not null then ', mit Leitungsplanstelle „' || v_leitung || '“' else '' end); + + -- Die Leitungsplanstelle entsteht über create_position und nicht durch eine + -- zweite Fassung derselben Logik. Dort hängen der Jobkatalog (gleiche + -- Tätigkeit = ein Eintrag), die Nummernvergabe und die Prüfung „je Einheit + -- genau eine Leitung“ — nachgebaut liefe das hier beim ersten Zusatz + -- auseinander. Sie schreibt ihren eigenen Protokolleintrag. + if v_leitung is not null then + perform create_position(jsonb_build_object( + 'org_unit_id', v_id, + 'job_title', v_leitung, + 'is_chief', true, + 'valid_from', v_valid_from + )); + end if; + + return v_id; +end; +$$; + +comment on function create_org_unit(jsonb) is + 'Legt eine Organisationseinheit unter einer bestehenden an, wahlweise gleich mit Leitungsplanstelle. Nur anlegen: umbenennen, verschieben und schliessen gibt es bewusst nicht, weil parent_id kein Datum trägt.'; + +grant execute on function create_org_unit(jsonb) to alpenwerk_app; + + +-- Selbstprüfung. +do $$ +declare + v_def text; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'create_org_unit' and p.prokind = 'f'; + + if v_def is null then + raise exception 'create_org_unit wurde nicht angelegt'; + end if; + if v_def not like '%require_hr_admin()%' then + raise exception 'create_org_unit prüft die Rechte nicht'; + end if; + -- Ohne search_path liesse sich der Funktion über einen eigenen Schemapfad + -- eine andere Tabelle unterschieben. Hauskonvention für jede Funktion. + if v_def not like '%search_path%' then + raise exception 'create_org_unit hat keinen festen search_path'; + end if; + if v_def not like '%audit_log%' then + raise exception 'create_org_unit schreibt nichts ins Protokoll'; + end if; + -- Nicht nachgebaut, sondern aufgerufen — siehe den Kommentar dort. + if v_def not like '%create_position(%' then + raise exception 'create_org_unit legt die Leitungsplanstelle nicht über create_position an'; + end if; + -- Ohne Übergeordnete entstünde eine zweite Wurzel. + if v_def not like '%parent_id%' then + raise exception 'create_org_unit kennt die übergeordnete Einheit nicht'; + end if; +end +$$; diff --git a/lib/org-nummer.ts b/lib/org-nummer.ts new file mode 100644 index 0000000..af9d9b0 --- /dev/null +++ b/lib/org-nummer.ts @@ -0,0 +1,46 @@ +// Ein Vorschlag für die nächste Orgnummer. +// +// Die Nummer wird eingetragen, nicht erzeugt — wie die Personalnummer, und aus +// demselben Grund: sie stammt aus dem führenden System des Kunden, und eine +// selbst vergebene Nummer wäre eine, die dort niemand kennt. Ein leeres Feld +// in einem Dialog, der sonst zwei Klicks braucht, ist aber die Stelle, an der +// jemand ins Stocken gerät. +// +// Deshalb ein **Vorschlag** und keine Vergabe: erkennbar ist nur, was sich aus +// dem Bestand ablesen lässt, und wenn sich nichts ablesen lässt, steht hier +// nichts. Geraten wird nicht — eine plausibel aussehende, aber erfundene +// Systematik wäre schlimmer als ein leeres Feld, weil sie niemand nachprüft. +// +// Erkannt wird genau ein Muster: gleichbleibender Vorspann, dann Ziffern +// („OE-0013“, „10000001“). Führende Nullen bleiben in der Breite erhalten, die +// der Bestand führt. + +const MUSTER = /^(.*?)(\d+)$/; + +export function naechsteOrgnummer(vorhandene: readonly string[]): string | undefined { + if (vorhandene.length === 0) return undefined; + + let vorspann: string | null = null; + let breite = 0; + let hoechste = 0; + + for (const nummer of vorhandene) { + const treffer = MUSTER.exec(nummer.trim()); + if (!treffer) return undefined; + + const [, kopf, ziffern] = treffer; + // Zwei Systematiken nebeneinander („OE-0013“ und „B7“) — dann gibt es + // keine nächste Nummer, sondern zwei Reihen, und welche gemeint ist, + // weiss nur der Mensch vor dem Dialog. + if (vorspann === null) vorspann = kopf; + else if (vorspann !== kopf) return undefined; + + breite = Math.max(breite, ziffern.length); + hoechste = Math.max(hoechste, Number(ziffern)); + } + + const naechste = hoechste + 1; + // Wächst die Zahl über die bisherige Breite hinaus, wird sie eben länger — + // abzuschneiden hiesse, eine bereits vergebene Nummer vorzuschlagen. + return `${vorspann}${String(naechste).padStart(breite, "0")}`; +} diff --git a/lib/types.ts b/lib/types.ts index bb77345..163babc 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -761,6 +761,10 @@ export type Database = { // Kopie einer bestehenden Planstelle — gleiche Einheit, Tätigkeit und // Kontierung, neue Nummer, unbesetzt. Leitungsplanstellen ausgenommen. clone_position: { Args: { payload: Record }; Returns: string }; + // Eine Organisationseinheit unter einer bestehenden. Nur anlegen: + // parent_id trägt kein Datum, ein Verschieben änderte deshalb auch die + // Vergangenheit — siehe Migration 20260929120000. + create_org_unit: { Args: { payload: Record }; Returns: string }; update_position: { Args: { payload: Record }; Returns: void }; set_position_cost_center: { Args: { payload: Record }; Returns: void }; set_onboarding_task: { Args: { payload: Record }; Returns: void }; diff --git a/tests/components/PositionTree.test.tsx b/tests/components/PositionTree.test.tsx new file mode 100644 index 0000000..e9b7426 --- /dev/null +++ b/tests/components/PositionTree.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { buildUnitTree, PositionTree } from "@/components/orgchart/PositionTree"; +import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "@/components/orgchart/types"; + +vi.mock("@/actions/org", () => ({ createOrgUnit: vi.fn(async () => ({ success: true })) })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) })); + +// Der Knopf „Einheit darunter anlegen" hängt allein daran, dass ein Knoten +// eine `unitId` trägt. Verschwindet die still — etwa weil jemand den Baum +// umbaut —, bleibt die Oberfläche fehlerfrei und die Handlung ist weg. + +const UNITS: OrgUnitNode[] = [ + { id: "u1", org_number: "OE-0001", name: "Gesellschaft", parent_id: null, unit_type: "Gesellschaft" }, + { id: "u2", org_number: "OE-0010", name: "Produktion", parent_id: "u1", unit_type: "Bereich" }, +]; + +const EMPLOYEES: OrgEmployee[] = [ + { + id: "p1", + personnel_number: 1, + first_name: "Anna", + last_name: "Huber", + job_title: "Maschinenbedienung", + manager_id: null, + formal_manager_id: null, + absent: false, + absence_type: null, + org_unit_id: "u2", + is_chief: false, + position_id: "s1", + position_number: "S-0001", + }, +]; + +const VACANCIES: OrgVacancy[] = [ + { position_id: "s2", position_number: "S-0002", job_title: "Schlosser:in", org_unit_id: "u2", is_chief: false }, +]; + +describe("buildUnitTree", () => { + const baum = buildUnitTree(UNITS, EMPLOYEES, VACANCIES); + + it("trägt die Einheit an jedem Einheitenknoten", () => { + expect(baum[0].unitId).toBe("u1"); + expect(baum[0].children[0].unitId).toBe("u2"); + }); + + it("trägt sie an nichts anderem", () => { + // Eine Person, eine Tätigkeitsgruppe oder eine Vakanz ist keine Einheit — + // ein Plus daran würde etwas anlegen, das woanders hinge. + const bereich = baum[0].children[0]; + for (const kind of bereich.children.filter((k) => k.kind !== "role")) { + expect(kind.unitId, `„${kind.label}" darf keine Einheit tragen`).toBeUndefined(); + for (const enkel of kind.children) expect(enkel.unitId).toBeUndefined(); + } + }); +}); + +describe("PositionTree", () => { + it("bietet je Einheit einen Knopf zum Anlegen an", async () => { + render(); + // Die Wurzel ist aufgeklappt; ihr Kind ist damit sichtbar. + expect(await screen.findByRole("button", { name: /Einheit unter .*Gesellschaft anlegen/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Einheit unter .*Produktion anlegen/ })).toBeInTheDocument(); + }); + + it("bietet ihn nicht an einer Person an", () => { + render(); + expect(screen.queryByRole("button", { name: /Einheit unter Huber/ })).not.toBeInTheDocument(); + }); +}); diff --git a/tests/unit/org-nummer.test.ts b/tests/unit/org-nummer.test.ts new file mode 100644 index 0000000..8a7925c --- /dev/null +++ b/tests/unit/org-nummer.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { naechsteOrgnummer } from "@/lib/org-nummer"; + +// Der Vorschlag darf lieber schweigen als raten: eine erfundene, aber +// plausibel aussehende Nummer prüft niemand nach, und sie landet im führenden +// System des Kunden als Kennung, die es dort nicht gibt. + +describe("naechsteOrgnummer", () => { + it("zählt die höchste Nummer hoch und behält die Breite", () => { + expect(naechsteOrgnummer(["OE-0001", "OE-0013", "OE-0080"])).toBe("OE-0081"); + }); + + it("richtet sich nach der höchsten, nicht nach der letzten in der Liste", () => { + expect(naechsteOrgnummer(["OE-0080", "OE-0001", "OE-0013"])).toBe("OE-0081"); + }); + + it("kommt auch ohne Vorspann aus", () => { + expect(naechsteOrgnummer(["10000001", "10000002"])).toBe("10000003"); + }); + + it("verlängert, statt eine vergebene Nummer vorzuschlagen", () => { + // 99 + 1 passt nicht mehr in zwei Stellen. Abgeschnitten käme „00“ + // heraus, und das gibt es bereits. + expect(naechsteOrgnummer(["OE-98", "OE-99"])).toBe("OE-100"); + }); + + it("schweigt bei zwei Systematiken nebeneinander", () => { + expect(naechsteOrgnummer(["OE-0001", "B7"])).toBeUndefined(); + }); + + it("schweigt, wenn eine Nummer gar nicht auf Ziffern endet", () => { + expect(naechsteOrgnummer(["OE-0001", "Vertrieb"])).toBeUndefined(); + }); + + it("schweigt im leeren Bestand", () => { + expect(naechsteOrgnummer([])).toBeUndefined(); + }); +});