Die Kostenstelle liess sich an genau einer Stelle aendern: auf der Seite Positionen, ueber die Karte einer Vakanz. Diese Seite zeigt aber nur unbesetzte und kuenftige Planstellen. Bei einer besetzten war die Kontierung damit nirgends zu erreichen -- im Bestand betrifft das 784 von 788, und dasselbe galt fuer ihre Taetigkeit. Die Angaben zu einer Einheit fuehren jetzt ihre Planstellen auf, besetzte eingeschlossen, mit Inhaber:in und Kostenstelle. Ein Klick oeffnet denselben Dialog wie auf Positionen, kein zweiter daneben. Was sich aendern laesst, entscheidet weiterhin die Datenbank: bei einer besetzten Stelle bleiben Einheit und Gueltigkeitsende gesperrt, weil ein Abteilungswechsel ueber eine Versetzung gehoert und nicht ueber die Stelle. Geladen wird erst beim Oeffnen und nur fuer diese eine Einheit. Alle 788 Planstellen in jede Zeichnung des Organigramms zu legen hiesse, fuer achtzig Einheiten zu laden, was man fuer eine braucht. EditPositionModal verlangt dafuer nicht mehr eine OpenPositionResolved, sondern nur noch die Felder, die er tatsaechlich liest. Die alte Signatur war der Grund, warum er nur dort aufzumachen war, wo unbesetzte Stellen entstehen.
382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
"use client";
|
|
|
|
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 { OrgUnitPanel } from "./OrgUnitPanel";
|
|
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");
|
|
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
|
|
// 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]);
|
|
|
|
// Die Einheit, unter der gerade eine neue entstehen soll. Als Kennung und
|
|
// nicht als Objekt: nach dem Anlegen lädt die Seite neu, und ein
|
|
// festgehaltenes Objekt wäre dann der Stand von vorhin.
|
|
const [anlegenUnter, setAnlegenUnter] = useState<string | null>(null);
|
|
const onAddUnit = useCallback((unitId: string) => setAnlegenUnter(unitId), []);
|
|
const eltern = anlegenUnter ? (units.find((u) => u.id === anlegenUnter) ?? null) : null;
|
|
|
|
// Die Einheit, deren Angaben offen sind. Der Klick auf die Karte war bisher
|
|
// ohne Wirkung — bei achtzig Einheiten die naheliegendste Geste ohne Antwort.
|
|
const [gewaehlt, setGewaehlt] = useState<string | null>(null);
|
|
const onSelectUnit = useCallback((unitId: string) => setGewaehlt(unitId), []);
|
|
const einheit = gewaehlt ? (units.find((u) => u.id === gewaehlt) ?? 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 betroffen = units.find((u) => u.id === unitId);
|
|
const result = await deleteOrgUnit(unitId);
|
|
if (result.success) {
|
|
showToast(`${betroffen ? `${betroffen.org_number} · ${betroffen.name}` : "Einheit"} entfernt.`);
|
|
// Die Angaben zu einer Einheit, die es nicht mehr gibt, stünden sonst
|
|
// offen da.
|
|
setGewaehlt(null);
|
|
router.refresh();
|
|
} else {
|
|
showToast(result.error ?? "Fehler beim Entfernen.", "error");
|
|
}
|
|
},
|
|
[units, showToast, router]
|
|
);
|
|
|
|
const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]);
|
|
|
|
// Der Baum in der Form, die der Änderungsdialog einer Planstelle erwartet.
|
|
// Aus denselben Daten gerechnet statt zusätzlich geladen: `hasChief` heisst
|
|
// dort „diese Einheit hat schon eine Leitungsplanstelle", und das ist genau
|
|
// die Leitung im Baum — besetzt oder als Vakanz.
|
|
const unitOptions = useMemo(() => {
|
|
const tiefe = new Map<string, number>();
|
|
const berechne = (u: OrgUnitNode, gesehen: Set<string>): number => {
|
|
const bekannt = tiefe.get(u.id);
|
|
if (bekannt !== undefined) return bekannt;
|
|
if (!u.parent_id || gesehen.has(u.id)) return 0;
|
|
gesehen.add(u.id);
|
|
const eltern = units.find((e) => e.id === u.parent_id);
|
|
const d = eltern ? berechne(eltern, gesehen) + 1 : 0;
|
|
tiefe.set(u.id, d);
|
|
return d;
|
|
};
|
|
const mitLeitung = new Set([
|
|
...employees.filter((e) => e.is_chief).map((e) => e.org_unit_id),
|
|
...vacancies.filter((v) => v.is_chief).map((v) => v.org_unit_id),
|
|
]);
|
|
return units.map((u) => ({
|
|
id: u.id,
|
|
name: u.name,
|
|
unit_type: u.unit_type,
|
|
depth: berechne(u, new Set()),
|
|
hasChief: mitLeitung.has(u.id),
|
|
}));
|
|
}, [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}
|
|
onAddUnit={onAddUnit}
|
|
onDeleteUnit={onDeleteUnit}
|
|
onSelectUnit={onSelectUnit}
|
|
/>
|
|
) : (
|
|
<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}
|
|
onAddUnit={onAddUnit}
|
|
onDeleteUnit={onDeleteUnit}
|
|
onSelectUnit={onSelectUnit}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{eltern && <CreateOrgUnitModal parent={eltern} units={units} onClose={() => setAnlegenUnter(null)} />}
|
|
|
|
{einheit && (
|
|
<OrgUnitPanel
|
|
unit={einheit}
|
|
units={units}
|
|
unitOptions={unitOptions}
|
|
employees={employees}
|
|
vacancies={vacancies}
|
|
onAddUnit={onAddUnit}
|
|
onDeleteUnit={onDeleteUnit}
|
|
onClose={() => setGewaehlt(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ListNode({
|
|
node,
|
|
depth,
|
|
expanded,
|
|
onToggle,
|
|
onAddUnit,
|
|
onDeleteUnit,
|
|
onSelectUnit,
|
|
}: {
|
|
node: ChartNode;
|
|
depth: number;
|
|
expanded: Set<string>;
|
|
onToggle: (id: string) => void;
|
|
onAddUnit: (unitId: string) => void;
|
|
onDeleteUnit: (unitId: string) => void;
|
|
onSelectUnit: (unitId: string) => void;
|
|
}) {
|
|
const expandable = node.children.length > 0;
|
|
const open = expanded.has(node.id);
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
className="group/zeile 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>
|
|
) : node.unitId ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelectUnit(node.unitId!)}
|
|
aria-label={`Angaben zu ${node.label}`}
|
|
className="rounded text-left text-sm font-semibold text-ink hover:text-brand-700 hover:underline
|
|
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
|
>
|
|
{node.label}
|
|
</button>
|
|
) : (
|
|
<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>
|
|
)}
|
|
{/* 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 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onDeleteUnit(node.unitId!)}
|
|
aria-label={`Einheit ${node.label} entfernen`}
|
|
title="Einheit entfernen"
|
|
className="ml-1 shrink-0 rounded p-0.5 text-ink-muted opacity-0 transition-opacity hover:text-danger-solid
|
|
focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500
|
|
group-hover/zeile:opacity-100 [@media(hover:none)]:opacity-100"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
{node.unitId && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onAddUnit(node.unitId!)}
|
|
aria-label={`Einheit unter ${node.label} anlegen`}
|
|
title="Untergeordnete Einheit anlegen"
|
|
className="ml-1 shrink-0 rounded p-0.5 text-ink-muted opacity-0 transition-opacity hover:text-brand-700
|
|
focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500
|
|
group-hover/zeile:opacity-100 [@media(hover:none)]:opacity-100"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{open &&
|
|
node.children.map((child) => (
|
|
<ListNode
|
|
key={child.id}
|
|
node={child}
|
|
depth={depth + 1}
|
|
expanded={expanded}
|
|
onToggle={onToggle}
|
|
onAddUnit={onAddUnit}
|
|
onDeleteUnit={onDeleteUnit}
|
|
onSelectUnit={onSelectUnit}
|
|
/>
|
|
))}
|
|
</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",
|
|
unitId: unit.id,
|
|
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);
|
|
}
|