Files
alpenwerk-hr/components/orgchart/PositionTree.tsx
Andrei Laas 948ddf5c70
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m41s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m16s
Verhaeltnis als Auswahlliste, und im Organigramm keine Vakanz ganz oben
Zwei Nachtraege zur Rueckmeldung des Kunden.

Das Fenster fuer den Notfallkontakt hatte ein Freitextfeld fuer das
Verhaeltnis, waehrend der Einstellungsassistent und "Daten aendern"
dieselbe Auswahlliste fuehren (EMERGENCY_RELATIONS). Freitext hiesse,
dass "Gattin", "Ehefrau" und "Frau" nebeneinander stehen und sich nicht
auswerten lassen -- genau der Grund, aus dem die Liste ueberhaupt
existiert. Jetzt fuehren alle drei Stellen dieselbe.

Und "Leitung vakant" stand im Organigramm weiterhin bei der obersten
Einheit. Entfernt wurde es zuletzt nur im Druck; die Ansicht am
Bildschirm hat ihre eigene Beschriftung. Die Gesellschaft wird nicht
gefuehrt, sondern ist das Ganze -- der Vermerk las sich dort wie eine
offene Stelle und faerbte den obersten Knoten obendrein als Vakanz ein.
Ueberall sonst bleibt er.
2026-09-23 12:58:57 +02:00

224 lines
7.9 KiB
TypeScript

"use client";
import { ChevronDown, ChevronRight } from "lucide-react";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
import { fmtName } from "@/lib/format";
// Die Struktursicht. Sie folgt jetzt org_units.parent_id statt einer fest
// verdrahteten Abfolge Bereich → Abteilung → Team: eine fünfte Ebene ist
// damit eine Datenfrage und keine Änderung an dieser Datei.
//
// Liste und Grafik werden aus *einem* Baum gerendert. Vorher gab es dieselbe
// Hierarchie zweimal — einmal als JSX-Schachtelung, einmal als ChartNode —
// und die beiden konnten auseinanderlaufen, ohne dass es auffiel.
type ViewMode = "list" | "graph";
type PositionTreeProps = {
employees: OrgEmployee[];
units: OrgUnitNode[];
vacancies: OrgVacancy[];
};
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");
// useCallback-stabil: das Layout-Memo von GraphOrgChart hängt an diesen
// Referenzen, instabile Funktionen erzwängen sonst bei jedem Re-Render ein
// neues Dagre-Layout.
const toggle = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]);
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<SegmentedControl<ViewMode>
value={mode}
onChange={setMode}
options={[
{ value: "list", label: "Liste" },
{ value: "graph", label: "Grafisch" },
]}
/>
</div>
{mode === "graph" ? (
<LazyGraphOrgChart tree={tree} isExpanded={isExpanded} onToggle={toggle} />
) : (
<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} />
))}
</div>
)}
</div>
);
}
function ListNode({
node,
depth,
expanded,
onToggle,
}: {
node: ChartNode;
depth: number;
expanded: Set<string>;
onToggle: (id: string) => void;
}) {
const expandable = node.children.length > 0;
const open = expanded.has(node.id);
return (
<div>
<div
className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
style={{ paddingLeft: depth * 24 + 8 }}
>
{expandable ? (
<button
type="button"
onClick={() => onToggle(node.id)}
className="shrink-0 text-ink-muted"
aria-expanded={open}
aria-label={open ? `${node.label} zuklappen` : `${node.label} aufklappen`}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<span className="w-4 shrink-0" />
)}
{node.href ? (
<Link
href={node.href}
className={node.kind === "vacancy" ? "text-sm text-brand-700 hover:underline" : "text-sm text-ink-body hover:text-brand-700 hover:underline"}
>
{node.label}
</Link>
) : (
<span className={node.kind === "group" ? "text-sm text-ink" : "text-sm font-semibold text-ink"}>{node.label}</span>
)}
{node.sublabel && (
<span className={node.vacant ? "text-xs font-semibold text-warning-text" : "text-xs text-ink-muted"}>{node.sublabel}</span>
)}
</div>
{open && node.children.map((child) => <ListNode key={child.id} node={child} depth={depth + 1} expanded={expanded} onToggle={onToggle} />)}
</div>
);
}
/**
* Je Einheit: die Leitung als Kopfzeile, darunter die untergeordneten
* Einheiten, dann die eigenen Mitarbeitenden nach Tätigkeit gruppiert und
* zuletzt die unbesetzten Planstellen.
*/
export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], vacancies: OrgVacancy[]): ChartNode[] {
const childUnits = new Map<string | null, OrgUnitNode[]>();
for (const u of units) {
const list = childUnits.get(u.parent_id) ?? [];
list.push(u);
childUnits.set(u.parent_id, list);
}
for (const list of childUnits.values()) list.sort((a, b) => a.org_number.localeCompare(b.org_number));
const chiefOf = new Map<string, OrgEmployee>();
const staffOf = new Map<string, OrgEmployee[]>();
for (const e of employees) {
if (e.is_chief) chiefOf.set(e.org_unit_id, e);
else {
const list = staffOf.get(e.org_unit_id) ?? [];
list.push(e);
staffOf.set(e.org_unit_id, list);
}
}
const vacantOf = new Map<string, OrgVacancy[]>();
for (const v of vacancies) {
const list = vacantOf.get(v.org_unit_id) ?? [];
list.push(v);
vacantOf.set(v.org_unit_id, list);
}
function build(unit: OrgUnitNode): ChartNode {
const key = `unit-${unit.id}`;
const chief = chiefOf.get(unit.id);
const subUnits = (childUnits.get(unit.id) ?? []).map(build);
// Nach Tätigkeit gruppiert: dreissig Zeilen „Maschinenbediener:in" sagen
// weniger als eine Zeile „Maschinenbediener:in — 30x besetzt".
const byTitle = new Map<string, OrgEmployee[]>();
for (const e of staffOf.get(unit.id) ?? []) {
const list = byTitle.get(e.job_title) ?? [];
list.push(e);
byTitle.set(e.job_title, list);
}
const titleGroups: ChartNode[] = Array.from(byTitle.entries())
.sort(([a], [b]) => a.localeCompare(b, "de"))
.map(([title, people]) => ({
id: `${key}-job-${title}`,
kind: "group" as const,
label: title,
sublabel: `${people.length}x besetzt`,
children: people
.slice()
.sort((a, b) => a.last_name.localeCompare(b.last_name, "de"))
.map((p) => ({
id: p.id,
kind: "person" as const,
label: fmtName(p.first_name, p.last_name),
href: `/employees/${p.id}`,
avatar: { firstName: p.first_name, lastName: p.last_name },
absent: p.absent,
badge: p.absent ? (p.absence_type ?? "Abwesend") : undefined,
children: [],
})),
}));
const vacancyNodes: ChartNode[] = (vacantOf.get(unit.id) ?? [])
.filter((v) => !v.is_chief) // die Leitungsvakanz steht schon in der Kopfzeile
.sort((a, b) => a.position_number.localeCompare(b.position_number))
.map((v) => ({
id: `vac-${v.position_id}`,
kind: "vacancy" as const,
label: `+ ${v.position_number} · ${v.job_title}`,
href: "/positions",
vacant: true,
children: [],
}));
return {
id: key,
kind: "role",
label: `${unit.org_number} · ${unit.name}`,
// Die Gesellschaft bleibt ohne Vermerk: sie wird nicht geführt, sondern
// ist das Ganze. „Leitung vakant" las sich dort wie eine offene Stelle,
// die jemand besetzen müsste, und färbte obendrein den obersten Knoten
// als Vakanz ein. Überall sonst ist der Vermerk genau das und gehört
// hin — wie im Druck (PrintChart).
sublabel: chief
? `Leitung: ${fmtName(chief.first_name, chief.last_name)}${chief.absent ? " (abwesend)" : ""}`
: unit.unit_type === "Gesellschaft"
? undefined
: "Leitung vakant",
vacant: !chief && unit.unit_type !== "Gesellschaft",
children: [...subUnits, ...titleGroups, ...vacancyNodes],
};
}
return (childUnits.get(null) ?? []).map(build);
}