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