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 && (