Eine leere Organisationseinheit wieder entfernen
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m29s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s

Der Papierkorb neben dem Plus, im Organigramm wie in der Liste, und nur dort,
wo nichts darunter haengt. Ein Knopf, der beim Klick eine Absage erteilt, ist
schlechter als kein Knopf -- die Datenbank prueft es trotzdem, denn sie sieht
auch geschlossene Planstellen, die im Baum gar nicht gezeichnet sind.

Abgewiesen wird mit Grund: untergeordnete Einheiten, Planstellen (geschlossene
zaehlen mit), Kostenstellen, die darauf verweisen, und die Wurzel selbst.

Geloescht und nicht geschlossen, und das ist eine bewusste Einschraenkung.
delete_position macht seit dem OM-Umbau den Unterschied vor: besetzt gewesen →
valid_to, nie besetzt gewesen → weg. Fuer Einheiten laesst sich davon heute nur
die zweite Haelfte umsetzen, weil ein valid_to an einer Einheit zwar
eingetragen, aber nirgends gelesen wuerde -- weder Organigramm noch
orgMapsAbfragen, Berichte, Druck oder die Auswahl beim Anlegen einer Planstelle
schraenken org_units auf den Stichtag ein. Die geschlossene Einheit staende
ueberall weiter da, nur mit einem Datum, das niemand sieht. Das Schliessen zum
Stichtag kommt, wenn org_units gegen den Stichtag gelesen wird -- dieselbe
Arbeit, die auch das Verschieben braucht.
This commit is contained in:
2026-09-28 16:51:26 +02:00
parent a6b6a7d67c
commit a3dde61ec1
7 changed files with 276 additions and 24 deletions

View File

@@ -13,6 +13,8 @@ import { runMutation, type ActionResult } from "@/lib/db/rpc";
* damit auch jede Auswertung auf einen vergangenen Stichtag. Siehe Migration
* 20260929120000.
*/
const ORG_PFADE = ["/orgchart", "/positions", "/"];
export async function createOrgUnit(payload: {
parent_id: string;
org_number: string;
@@ -26,6 +28,20 @@ export async function createOrgUnit(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);
for (const path of ORG_PFADE) revalidatePath(path);
return result;
}
/**
* Eine Organisationseinheit entfernen.
*
* Geht nur, solange nichts darunter hängt und nie etwas hing. Alles andere
* weist die Datenbankfunktion mit dem Grund ab — ein Schliessen zum Stichtag
* gibt es nicht, weil org_units nirgends gegen den Stichtag gelesen wird.
*/
export async function deleteOrgUnit(orgUnitId: string): Promise<ActionResult> {
const result = await runMutation(await currentUserId(), "delete_org_unit", { org_unit_id: orgUnitId });
if (!result.success) return result;
for (const path of ORG_PFADE) revalidatePath(path);
return result;
}

View File

@@ -41,6 +41,7 @@ export type GraphOrgChartProps = {
matchedIds?: Set<string> | null;
/** Nur die Struktursicht reicht sie herein; in der Mitarbeitersicht gibt es keine Einheiten. */
onAddUnit?: (unitId: string) => void;
onDeleteUnit?: (unitId: string) => void;
};
export function GraphOrgChart(props: GraphOrgChartProps) {
@@ -51,7 +52,7 @@ export function GraphOrgChart(props: GraphOrgChartProps) {
);
}
function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds, onAddUnit }: GraphOrgChartProps) {
function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds, onAddUnit, onDeleteUnit }: GraphOrgChartProps) {
const { visibleNodes, visibleEdges } = useMemo(() => collectVisible(tree, isExpanded), [tree, isExpanded]);
const { rfNodes, rfEdges } = useMemo(() => {
@@ -68,6 +69,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds, onAddUnit
childCount: n.children.length,
onToggle,
onAddUnit,
onDeleteUnit,
},
}));
const rfEdges: Edge[] = visibleEdges.map((e) => ({
@@ -79,7 +81,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds, onAddUnit
style: { stroke: "#e3cddb", strokeWidth: 1.5 },
}));
return { rfNodes, rfEdges };
}, [visibleNodes, visibleEdges, isExpanded, onToggle, onAddUnit]);
}, [visibleNodes, visibleEdges, isExpanded, onToggle, onAddUnit, onDeleteUnit]);
const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges);

View File

@@ -1,7 +1,7 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { Building2, ChevronDown, Plus, UserRound } from "lucide-react";
import { Building2, ChevronDown, Plus, Trash2, UserRound } from "lucide-react";
import Link from "next/link";
import { memo } from "react";
import { Avatar } from "@/components/ui/Avatar";
@@ -16,6 +16,7 @@ export type OrgChartNodeData = {
onToggle: (id: string) => void;
/** Nur in der Struktursicht gesetzt — ohne sie bleibt die Karte unverändert. */
onAddUnit?: (unitId: string) => void;
onDeleteUnit?: (unitId: string) => void;
};
export type OrgChartRFNode = Node<OrgChartNodeData, "orgNode">;
@@ -44,7 +45,7 @@ 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, onAddUnit } = data;
const { chartNode, expanded, hasChildren, childCount, onToggle, onAddUnit, onDeleteUnit } = 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];
@@ -113,22 +114,45 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
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 && (
{unitId && (onAddUnit || onDeleteUnit) && (
<span
className="nodrag nopan absolute -right-2.5 -top-2.5 z-10 flex items-center gap-1
opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100
[@media(hover:none)]:opacity-100"
>
{/* Der Papierkorb nur am Blatt. Eine Einheit mit Kindern lässt sich
ohnehin nicht entfernen — der Knopf wäre dort ein Angebot, das
beim Klick eine Absage erteilt, und das ist schlechter als kein
Knopf. Die Datenbank prüft es trotzdem: sie sieht auch
geschlossene Planstellen, die hier gar nicht gezeichnet sind. */}
{onDeleteUnit && !hasChildren && (
<button
type="button"
onClick={() => onDeleteUnit(unitId)}
aria-label={`Einheit ${label} entfernen`}
title="Einheit entfernen"
className="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-danger-solid hover:bg-danger-solid hover:text-white
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<Trash2 className="h-3 w-3" />
</button>
)}
{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"
className="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"
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
</span>
)}
{/* 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,12 @@
"use client";
import { ChevronDown, ChevronRight, Plus } from "lucide-react";
import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { deleteOrgUnit } from "@/actions/org";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { useToast } from "@/components/ui/Toast";
import { CreateOrgUnitModal } from "./CreateOrgUnitModal";
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
@@ -28,6 +31,8 @@ type PositionTreeProps = {
export function PositionTree({ employees, units, vacancies }: PositionTreeProps) {
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(units.filter((u) => u.parent_id === null).map((u) => `unit-${u.id}`)));
const [mode, setMode] = useState<ViewMode>("list");
const { showToast } = useToast();
const router = useRouter();
// useCallback-stabil: das Layout-Memo von GraphOrgChart hängt an diesen
// Referenzen, instabile Funktionen erzwängen sonst bei jedem Re-Render ein
@@ -49,6 +54,23 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
const onAddUnit = useCallback((unitId: string) => setAnlegenUnter(unitId), []);
const eltern = anlegenUnter ? (units.find((u) => u.id === anlegenUnter) ?? null) : null;
// Ohne Rückfrage, wie beim Entfernen einer Planstelle: entfernbar ist nur,
// was nie eine Planstelle getragen hat und nichts unter sich hat. Alles
// andere weist die Datenbank mit dem Grund ab, und der steht dann im Toast.
const onDeleteUnit = useCallback(
async (unitId: string) => {
const einheit = units.find((u) => u.id === unitId);
const result = await deleteOrgUnit(unitId);
if (result.success) {
showToast(`${einheit ? `${einheit.org_number} · ${einheit.name}` : "Einheit"} entfernt.`);
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Entfernen.", "error");
}
},
[units, showToast, router]
);
const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]);
return (
@@ -64,11 +86,25 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
/>
</div>
{mode === "graph" ? (
<LazyGraphOrgChart tree={tree} isExpanded={isExpanded} onToggle={toggle} onAddUnit={onAddUnit} />
<LazyGraphOrgChart
tree={tree}
isExpanded={isExpanded}
onToggle={toggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
/>
) : (
<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} onAddUnit={onAddUnit} />
<ListNode
key={node.id}
node={node}
depth={0}
expanded={expanded}
onToggle={toggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
/>
))}
</div>
)}
@@ -84,12 +120,14 @@ function ListNode({
expanded,
onToggle,
onAddUnit,
onDeleteUnit,
}: {
node: ChartNode;
depth: number;
expanded: Set<string>;
onToggle: (id: string) => void;
onAddUnit: (unitId: string) => void;
onDeleteUnit: (unitId: string) => void;
}) {
const expandable = node.children.length > 0;
const open = expanded.has(node.id);
@@ -129,6 +167,19 @@ function ListNode({
{/* 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 && !expandable && (
<button
type="button"
onClick={() => onDeleteUnit(node.unitId!)}
aria-label={`Einheit ${node.label} entfernen`}
title="Einheit entfernen"
className="ml-1 shrink-0 rounded p-0.5 text-ink-muted opacity-0 transition-opacity hover:text-danger-solid
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"
>
<Trash2 className="h-3 w-3" />
</button>
)}
{node.unitId && (
<button
type="button"
@@ -145,7 +196,15 @@ function ListNode({
</div>
{open &&
node.children.map((child) => (
<ListNode key={child.id} node={child} depth={depth + 1} expanded={expanded} onToggle={onToggle} onAddUnit={onAddUnit} />
<ListNode
key={child.id}
node={child}
depth={depth + 1}
expanded={expanded}
onToggle={onToggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
/>
))}
</div>
);

View File

@@ -0,0 +1,127 @@
-- Eine Organisationseinheit wieder entfernen
--
-- Das Gegenstück zu create_org_unit und mit derselben Einschränkung: es geht
-- nur dort, wo nichts umgeschrieben wird.
--
-- ═══ Warum gelöscht und nicht geschlossen ═════════════════════════
--
-- delete_position macht es seit dem OM-Umbau vor: besetzt → Abweisung, einmal
-- besetzt gewesen → `valid_to` statt Löschen (sonst verschwände mit der Stelle
-- die Besetzungshistorie), nie besetzt gewesen → weg. Der Unterschied ist
-- keine Vorsicht, sondern eine Aussage: was nie getragen hat, hat es auch nie
-- gegeben.
--
-- Für Einheiten lässt sich davon heute nur die letzte Stufe umsetzen. Ein
-- `valid_to` an einer Einheit wäre zwar eingetragen, aber **nirgends
-- gelesen**: weder das Organigramm noch orgMapsAbfragen, die Berichte, der
-- Druck oder die Auswahl beim Anlegen einer Planstelle schränken org_units auf
-- den Stichtag ein. Die geschlossene Einheit stünde überall weiter da, nur mit
-- einem Datum, das niemand sieht — das wäre schlechter als die klare
-- Abweisung unten, weil es wie Erledigt aussieht.
--
-- Deshalb hier nur: eine Einheit, unter der nie etwas hing, verschwindet
-- ganz. Alles andere wird abgewiesen, mit dem Grund. Das Schliessen zum
-- Stichtag kommt, wenn org_units überall gegen den Stichtag gelesen wird —
-- dieselbe Arbeit, die auch das Verschieben braucht.
create or replace function delete_org_unit(payload jsonb)
returns void language plpgsql
set search_path = public, pg_temp
as $$
declare
v_id uuid := (payload->>'org_unit_id')::uuid;
v_unit record;
v_einheiten int;
v_stellen int;
begin
perform require_hr_admin();
select id, org_number, name, parent_id into v_unit from org_units where id = v_id;
if v_unit.id is null then
raise exception 'Die Organisationseinheit existiert nicht.';
end if;
if v_unit.parent_id is null then
raise exception 'Die Gesellschaft ist die Wurzel und kann nicht entfernt werden.';
end if;
select count(*) into v_einheiten from org_units where parent_id = v_id;
if v_einheiten > 0 then
raise exception 'Unter dieser Einheit hängen noch % Einheiten. Sie müssen zuerst woanders hin.', v_einheiten;
end if;
-- Geschlossene Planstellen zählen mit. Sie tragen weiterhin diese Einheit,
-- und mit ihr verschwände die Angabe, wo jemand einmal gesessen hat.
select count(*) into v_stellen from om_positions where org_unit_id = v_id;
if v_stellen > 0 then
raise exception
'In dieser Einheit bestehen noch % Planstellen (geschlossene zählen mit). Sie müssen zuerst entfernt oder verschoben werden.',
v_stellen;
end if;
-- Die Kostenstellen tragen die Einheit nur als Hinweis (siehe Migration
-- 20260816100000), nicht als Zuordnung. Sie stünden nach dem Löschen auf
-- einer Einheit, die es nicht mehr gibt — der Fremdschlüssel würde das
-- abweisen, also vorher und mit Grund.
if exists (select 1 from cost_centers where org_unit_id = v_id) then
raise exception 'Auf diese Einheit verweisen noch Kostenstellen.';
end if;
delete from org_units where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (app_current_user_id(), current_actor_name(), 'Organisationseinheit gelöscht',
v_unit.org_number || ' · ' || v_unit.name,
'Ohne untergeordnete Einheiten und ohne Planstellen');
end;
$$;
comment on function delete_org_unit(jsonb) is
'Entfernt eine Organisationseinheit, unter der nichts hängt und nie etwas hing. Einheiten mit Planstellen — auch geschlossenen — oder mit untergeordneten Einheiten werden abgewiesen; ein Schliessen zum Stichtag gibt es noch nicht, weil org_units nirgends gegen den Stichtag gelesen wird.';
grant execute on function delete_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 = 'delete_org_unit' and p.prokind = 'f';
if v_def is null then
raise exception 'delete_org_unit wurde nicht angelegt';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'delete_org_unit prüft die Rechte nicht';
end if;
if v_def not like '%search_path%' then
raise exception 'delete_org_unit hat keinen festen search_path';
end if;
if v_def not like '%audit_log%' then
raise exception 'delete_org_unit schreibt nichts ins Protokoll';
end if;
-- Die drei Abweisungen. Fällt eine bei einer Neuerzeugung weg, löscht die
-- Funktion weiter — nur mehr, als sie darf.
if v_def not like '%om_positions%' then
raise exception 'delete_org_unit prüft die Planstellen nicht';
end if;
if v_def not like '%parent_id = v_id%' then
raise exception 'delete_org_unit prüft die untergeordneten Einheiten nicht';
end if;
if v_def not like '%v_unit.parent_id is null%' then
raise exception 'delete_org_unit schützt die Wurzel nicht';
end if;
-- Das Gegenstück muss weiterhin dastehen: ein Löschen ohne Anlegen wäre
-- eine Einbahnstrasse.
if not exists (
select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'create_org_unit'
) then
raise exception 'create_org_unit fehlt';
end if;
end
$$;

View File

@@ -765,6 +765,8 @@ export type Database = {
// 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 };
// Nur, wenn nichts darunter hängt und nie etwas hing — siehe 20260929140000.
delete_org_unit: { Args: { payload: Record<string, unknown> }; Returns: void };
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

@@ -1,11 +1,19 @@
import { render, screen } from "@testing-library/react";
import { render as rtlRender, 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";
import { ToastProvider } from "@/components/ui/Toast";
vi.mock("@/actions/org", () => ({ createOrgUnit: vi.fn(async () => ({ success: true })) }));
vi.mock("@/actions/org", () => ({
createOrgUnit: vi.fn(async () => ({ success: true })),
deleteOrgUnit: vi.fn(async () => ({ success: true })),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
function render(ui: React.ReactElement) {
return rtlRender(<ToastProvider>{ui}</ToastProvider>);
}
// 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.
@@ -68,4 +76,18 @@ describe("PositionTree", () => {
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
expect(screen.queryByRole("button", { name: /Einheit unter Huber/ })).not.toBeInTheDocument();
});
it("bietet den Papierkorb nur an, wo nichts darunter hängt", () => {
// „Produktion" trägt eine Person und eine Vakanz, die Gesellschaft trägt
// „Produktion" — entfernbar ist keine von beiden. Ein Knopf, der beim
// Klick eine Absage erteilt, ist schlechter als kein Knopf.
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
expect(screen.queryByRole("button", { name: /Einheit .* entfernen/ })).not.toBeInTheDocument();
});
it("bietet ihn an einer leeren Einheit an", () => {
const leer: OrgUnitNode = { id: "u3", org_number: "OE-0411", name: "Neu", parent_id: "u1", unit_type: "Bereich" };
render(<PositionTree employees={EMPLOYEES} units={[...UNITS, leer]} vacancies={VACANCIES} />);
expect(screen.getByRole("button", { name: /Einheit .*OE-0411.* entfernen/ })).toBeInTheDocument();
});
});