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

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[];
};