Eine Organisationseinheit aus dem Organigramm heraus anlegen
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m30s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m22s

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.
This commit is contained in:
2026-09-28 16:28:33 +02:00
parent 8b7e32f14a
commit a6b6a7d67c
11 changed files with 547 additions and 9 deletions

31
actions/org.ts Normal file
View File

@@ -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<ActionResult> {
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;
}

View File

@@ -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<Art>(() => 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 (
<Modal
open
onClose={onClose}
title="Einheit anlegen"
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending}>
Anlegen
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<p className="rounded-md bg-surface px-3 py-2 text-xs leading-relaxed text-ink-muted">
Unter <span className="font-semibold text-ink-body">{parent.org_number} · {parent.name}</span>.
Bestehende Einheiten lassen sich nicht hierher verschieben — die neue entsteht leer.
</p>
<TextField
label="Orgnummer"
required
value={orgNumber}
onChange={setOrgNumber}
hint="Vorgeschlagen wird die nächste freie Nummer der bestehenden Reihe. Sie muss zum führenden System passen."
/>
<TextField label="Bezeichnung" required value={name} onChange={setName} />
<SelectField label="Art" required value={art} onChange={(v) => setArt(v as Art)} options={ARTEN} />
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
<TextField
label="Leitungsplanstelle (optional)"
value={leitung}
onChange={setLeitung}
hint="Tätigkeit der Leitung, z. B. „Abteilungsleitung Einkauf“. Leer lassen, dann entsteht die Einheit ohne Leitung und ist im Organigramm als vakant zu sehen."
/>
</div>
</Modal>
);
}

View File

@@ -39,6 +39,8 @@ export type GraphOrgChartProps = {
isExpanded: (id: string) => boolean;
onToggle: (id: string) => void;
matchedIds?: Set<string> | 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);

View File

@@ -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<OrgChartNodeData, "orgNode">;
@@ -42,8 +44,8 @@ const KIND_SHELL: Record<ChartNodeKind, string> = {
// 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, 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<O
>
<Handle type="target" position={Position.Top} isConnectable={false} className="!invisible" />
{/* 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 && (
<button
type="button"
onClick={() => onAddUnit(unitId)}
aria-label={`Einheit unter ${label} anlegen`}
title="Untergeordnete Einheit anlegen"
className="nodrag nopan absolute -right-2.5 -top-2.5 z-10 flex h-6 w-6 items-center justify-center rounded-full
border border-border bg-white text-ink-muted shadow-sm transition-colors
hover:border-brand-500 hover:bg-brand-500 hover:text-white
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500
opacity-0 group-hover:opacity-100 focus-visible:opacity-100
[@media(hover:none)]:opacity-100"
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
{/* 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]}`} />

View File

@@ -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<string | null>(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)
/>
</div>
{mode === "graph" ? (
<LazyGraphOrgChart tree={tree} isExpanded={isExpanded} onToggle={toggle} />
<LazyGraphOrgChart tree={tree} isExpanded={isExpanded} onToggle={toggle} onAddUnit={onAddUnit} />
) : (
<div className="rounded border border-border bg-white p-4">
{tree.map((node) => (
<ListNode key={node.id} node={node} depth={0} expanded={expanded} onToggle={toggle} />
<ListNode key={node.id} node={node} depth={0} expanded={expanded} onToggle={toggle} onAddUnit={onAddUnit} />
))}
</div>
)}
{eltern && <CreateOrgUnitModal parent={eltern} units={units} onClose={() => setAnlegenUnter(null)} />}
</div>
);
}
@@ -73,11 +83,13 @@ function ListNode({
depth,
expanded,
onToggle,
onAddUnit,
}: {
node: ChartNode;
depth: number;
expanded: Set<string>;
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 (
<div>
<div
className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
className="group/zeile flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
style={{ paddingLeft: depth * 24 + 8 }}
>
{expandable ? (
@@ -114,8 +126,27 @@ function ListNode({
{node.sublabel && (
<span className={node.vacant ? "text-xs font-semibold text-warning-text" : "text-xs text-ink-muted"}>{node.sublabel}</span>
)}
{/* 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 && (
<button
type="button"
onClick={() => onAddUnit(node.unitId!)}
aria-label={`Einheit unter ${node.label} anlegen`}
title="Untergeordnete Einheit anlegen"
className="ml-1 shrink-0 rounded p-0.5 text-ink-muted opacity-0 transition-opacity hover:text-brand-700
focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500
group-hover/zeile:opacity-100 [@media(hover:none)]:opacity-100"
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
</div>
{open && node.children.map((child) => <ListNode key={child.id} node={child} depth={depth + 1} expanded={expanded} onToggle={onToggle} />)}
{open &&
node.children.map((child) => (
<ListNode key={child.id} node={child} depth={depth + 1} expanded={expanded} onToggle={onToggle} onAddUnit={onAddUnit} />
))}
</div>
);
}
@@ -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,

View File

@@ -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-<uuid>`) 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[];
};

View File

@@ -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
$$;

46
lib/org-nummer.ts Normal file
View File

@@ -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")}`;
}

View File

@@ -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<string, unknown> }; 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<string, unknown> }; Returns: string };
update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
set_position_cost_center: { Args: { payload: Record<string, unknown> }; Returns: void };
set_onboarding_task: { Args: { payload: Record<string, unknown> }; Returns: void };

View File

@@ -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(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
// 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(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
expect(screen.queryByRole("button", { name: /Einheit unter Huber/ })).not.toBeInTheDocument();
});
});

View File

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