Files
alpenwerk-hr/components/orgchart/OrgChartNode.tsx
Andrei Laas a6b6a7d67c
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m30s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m22s
Eine Organisationseinheit aus dem Organigramm heraus anlegen
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.
2026-09-28 16:28:33 +02:00

162 lines
7.0 KiB
TypeScript

"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { Building2, ChevronDown, Plus, UserRound } from "lucide-react";
import Link from "next/link";
import { memo } from "react";
import { Avatar } from "@/components/ui/Avatar";
import { NODE_DIMENSIONS } from "./graphLayout";
import type { ChartNode, ChartNodeKind } from "./types";
export type OrgChartNodeData = {
chartNode: ChartNode;
expanded: boolean;
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">;
// One accent colour per node kind, carried by a left stripe. It is what makes
// the four kinds separable at a glance when the chart is zoomed out far
// enough that the text has stopped being legible.
const KIND_ACCENT: Record<ChartNodeKind, string> = {
person: "bg-brand-500",
role: "bg-purple-text",
group: "bg-ink-muted",
// Rosa statt einer blassen Blaustufe: eine offene Stelle ist kein
// schwaecheres Abbild einer Person, sondern etwas anderes. Gegen das
// Blau der Person ist der Unterschied auch dann noch zu sehen, wenn
// herausgezoomt nur noch die Streifen uebrig sind.
vacancy: "bg-accent-500",
};
const KIND_SHELL: Record<ChartNodeKind, string> = {
person: "border-border bg-white",
role: "border-border bg-white",
group: "border-border-subtle bg-surface",
vacancy: "border-dashed border-accent-500 bg-accent-50",
};
// 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 { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor, unitId } = chartNode;
const isMatch = chartNode.matched ?? false;
const { width, height } = NODE_DIMENSIONS[kind];
const content = (
<>
{kind === "person" && avatar ? (
<Avatar firstName={avatar.firstName} lastName={avatar.lastName} size="sm" />
) : kind === "vacancy" ? (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed border-accent-500 text-accent-700">
<Plus className="h-3.5 w-3.5" />
</span>
) : kind === "role" ? (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-purple-bg text-purple-text">
<UserRound className="h-3.5 w-3.5" />
</span>
) : (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-ink-muted">
<Building2 className="h-3.5 w-3.5" />
</span>
)}
<span className="flex min-w-0 flex-1 flex-col">
<span title={label} className="truncate text-[13px] font-bold leading-tight text-ink">
{label}
</span>
{sublabel && (
<span
title={sublabel}
className={`truncate text-[11px] leading-tight ${vacant ? "font-semibold text-warning-text" : "text-ink-muted"}`}
>
{sublabel}
</span>
)}
{/* The stand-in replaces the report count rather than crowding in
next to it: on a card this size the arrangement is the more
important of the two, and it only appears while one is in force. */}
{absent ? (
<span className="mt-0.5 truncate text-[10px] font-semibold text-warning-text">
Abwesend{coveredBy ? ` · Vertretung: ${coveredBy}` : ""}
</span>
) : coveringFor ? (
<span className="mt-0.5 truncate text-[10px] font-semibold text-info-text">Vertretung für {coveringFor}</span>
) : (
totalReports !== undefined &&
totalReports > 0 && (
<span className="mt-0.5 text-[10px] font-semibold uppercase tracking-wide text-ink-muted">
{childCount} direkt · {totalReports} gesamt
</span>
)
)}
</span>
</>
);
return (
<div
style={{ width, height }}
className={`group relative flex items-center overflow-visible rounded-lg border shadow-sm transition-shadow hover:shadow-md ${
KIND_SHELL[kind]
} ${isMatch ? "ring-2 ring-brand-500 ring-offset-1" : ""}`}
>
<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]}`} />
{href ? (
<Link href={href} className="nodrag nopan flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">
{content}
</Link>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">{content}</div>
)}
{/* Overhangs the bottom edge, sitting on the connector to its children —
the conventional org-chart affordance, and a 28px touch target. */}
{hasChildren && (
<button
type="button"
onClick={() => onToggle(id)}
aria-expanded={expanded}
aria-label={expanded ? `${label} zuklappen` : `${label} aufklappen (${childCount})`}
className="nodrag nopan absolute -bottom-3.5 left-1/2 z-10 flex h-7 min-w-7 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-white px-1.5 text-[11px] font-bold text-ink-body shadow-sm transition-colors hover:border-brand-500 hover:bg-brand-500 hover:text-white"
>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : childCount}
</button>
)}
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!invisible" />
</div>
);
});