From a3dde61ec1f26552a8405eced99e9af5440c805d Mon Sep 17 00:00:00 2001 From: Andrei Laas Date: Mon, 28 Sep 2026 16:51:26 +0200 Subject: [PATCH] Eine leere Organisationseinheit wieder entfernen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- actions/org.ts | 18 ++- components/orgchart/GraphOrgChart.tsx | 6 +- components/orgchart/OrgChartNode.tsx | 54 +++++--- components/orgchart/PositionTree.tsx | 67 ++++++++- ...9140000_organisationseinheit_entfernen.sql | 127 ++++++++++++++++++ lib/types.ts | 2 + tests/components/PositionTree.test.tsx | 26 +++- 7 files changed, 276 insertions(+), 24 deletions(-) create mode 100644 db/migrations/20260929140000_organisationseinheit_entfernen.sql diff --git a/actions/org.ts b/actions/org.ts index 471afbe..5ca692b 100644 --- a/actions/org.ts +++ b/actions/org.ts @@ -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 { + 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; } diff --git a/components/orgchart/GraphOrgChart.tsx b/components/orgchart/GraphOrgChart.tsx index d9cf742..14e4042 100644 --- a/components/orgchart/GraphOrgChart.tsx +++ b/components/orgchart/GraphOrgChart.tsx @@ -41,6 +41,7 @@ export type GraphOrgChartProps = { matchedIds?: Set | 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); diff --git a/components/orgchart/OrgChartNode.tsx b/components/orgchart/OrgChartNode.tsx index 206436a..8e33adb 100644 --- a/components/orgchart/OrgChartNode.tsx +++ b/components/orgchart/OrgChartNode.tsx @@ -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; @@ -44,7 +45,7 @@ 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, 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,21 +114,44 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps 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 + {unitId && (onAddUnit || onDeleteUnit) && ( + - - + {/* 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 && ( + + )} + {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 1733f7d..79bce98 100644 --- a/components/orgchart/PositionTree.tsx +++ b/components/orgchart/PositionTree.tsx @@ -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>(() => new Set(units.filter((u) => u.parent_id === null).map((u) => `unit-${u.id}`))); const [mode, setMode] = useState("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) /> {mode === "graph" ? ( - + ) : (
{tree.map((node) => ( - + ))}
)} @@ -84,12 +120,14 @@ function ListNode({ expanded, onToggle, onAddUnit, + onDeleteUnit, }: { node: ChartNode; depth: number; expanded: Set; 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 && ( + + )} {node.unitId && (