Die naheliegendste Geste in dieser Ansicht war ohne Wirkung: eine Karte im Organigramm liess sich anklicken und antwortete nicht. Jetzt oeffnet sie eine Leiste mit dem, was an der Einheit steht -- Orgnummer, Art, die Kette nach oben, Gueltigkeit, Leitung, Belegschaft, unbesetzte Planstellen, die direkt untergeordneten Einheiten und die Zahl der Personen im ganzen Teilbaum. Dazu dieselben zwei Handlungen wie auf der Karte, nur beschriftet statt als Symbol. Alles daraus stammt aus den Daten, die die Struktursicht ohnehin geladen hat. Kein zusaetzlicher Gang zur Datenbank und damit auch keine zweite Wahrheit -- was die Leiste zeigt, ist dasselbe, woraus der Baum daneben gezeichnet ist. Die Liste klickt sich genauso: beide Ansichten kommen aus einem Baum, und eine Handlung, die es nur in einer von beiden gibt, findet man in der anderen nie. org_units liefert dafuer zusaetzlich valid_from und valid_to, aber nur an die Struktursicht. Der Druck kommt ohne sie aus; auf OrgUnitNode stehen sie deshalb als optionale Felder und nicht als zwei weitere Spalten, die ueberall mitgeschleppt werden.
200 lines
9.0 KiB
TypeScript
200 lines
9.0 KiB
TypeScript
"use client";
|
|
|
|
import { Handle, Position, type Node, type NodeProps } from "@xyflow/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";
|
|
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;
|
|
onDeleteUnit?: (unitId: string) => void;
|
|
/** Klick auf die Karte einer Einheit — öffnet ihre Angaben. */
|
|
onSelectUnit?: (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, onDeleteUnit, onSelectUnit } = 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 || 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="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]}`} />
|
|
|
|
{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>
|
|
) : unitId && onSelectUnit ? (
|
|
// Die ganze Karte, nicht ein Symbol darin: bei achtzig Einheiten ist
|
|
// die Fläche das Ziel, das man trifft, ohne hinzusehen.
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelectUnit(unitId)}
|
|
aria-label={`Angaben zu ${label}`}
|
|
className="nodrag nopan flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3 text-left
|
|
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
|
>
|
|
{content}
|
|
</button>
|
|
) : (
|
|
<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>
|
|
);
|
|
});
|