Files
alpenwerk-hr/components/orgchart/OrgUnitPanel.tsx
Andrei Laas ab50c21b8a
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m56s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m7s
Klick auf eine Einheit zeigt ihre Angaben
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.
2026-09-28 21:27:36 +02:00

165 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { Plus, Trash2 } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/Button";
import { SlideOver } from "@/components/ui/SlideOver";
import { fmtDate, fmtName } from "@/lib/format";
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
// Was an einer Organisationseinheit steht — beim Klick auf ihre Karte.
//
// Alles hier kommt aus den Daten, die die Struktursicht ohnehin geladen hat:
// Einheiten, Personen und Vakanzen zum eingestellten Stichtag. Kein zusätzlicher
// Gang zur Datenbank, und damit auch keine zweite Wahrheit — was die Karte
// zeigt, ist dasselbe, woraus der Baum daneben gezeichnet ist.
function Zeile({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex justify-between gap-4 border-b border-border-subtle py-1.5 last:border-0">
<span className="shrink-0 text-xs text-ink-muted">{label}</span>
<span className="text-right text-sm text-ink-body">{children}</span>
</div>
);
}
export function OrgUnitPanel({
unit,
units,
employees,
vacancies,
onAddUnit,
onDeleteUnit,
onClose,
}: {
unit: OrgUnitNode;
units: OrgUnitNode[];
employees: OrgEmployee[];
vacancies: OrgVacancy[];
onAddUnit: (unitId: string) => void;
onDeleteUnit: (unitId: string) => void;
onClose: () => void;
}) {
const kinder = units.filter((u) => u.parent_id === unit.id);
// Die Kette nach oben, mit Schutz gegen einen Kreis in parent_id. Der ist
// heute nicht herstellbar, aber diese Schleife wäre die erste Stelle, die
// ihn ausbaden müsste — und ein hängender Browser sagt niemandem, woran es
// liegt.
const kette: OrgUnitNode[] = [];
const gesehen = new Set<string>();
let lauf = unit.parent_id ? units.find((u) => u.id === unit.parent_id) : undefined;
while (lauf && !gesehen.has(lauf.id)) {
gesehen.add(lauf.id);
kette.unshift(lauf);
lauf = lauf.parent_id ? units.find((u) => u.id === lauf!.parent_id) : undefined;
}
const eigene = employees.filter((e) => e.org_unit_id === unit.id);
const leitung = eigene.find((e) => e.is_chief);
const belegschaft = eigene.filter((e) => !e.is_chief);
const offene = vacancies.filter((v) => v.org_unit_id === unit.id);
// Alles darunter, nicht nur die direkten Kinder — die Frage hinter „wie
// gross ist dieser Bereich" meint immer den ganzen Teilbaum.
const teilbaum = new Set<string>([unit.id]);
for (let gewachsen = true; gewachsen; ) {
gewachsen = false;
for (const u of units) {
if (u.parent_id && teilbaum.has(u.parent_id) && !teilbaum.has(u.id)) {
teilbaum.add(u.id);
gewachsen = true;
}
}
}
const imTeilbaum = employees.filter((e) => teilbaum.has(e.org_unit_id)).length;
// Dieselbe Bedingung wie am Papierkorb auf der Karte: nur ein Blatt ohne
// Planstellen lässt sich entfernen. Die Datenbank prüft strenger — sie sieht
// auch geschlossene Planstellen, die hier nicht gezeichnet sind.
const entfernbar = kinder.length === 0 && eigene.length === 0 && offene.length === 0 && unit.parent_id !== null;
return (
<SlideOver open onClose={onClose} title={unit.name} subtitle={`${unit.org_number} · ${unit.unit_type}`}>
<div className="flex flex-col gap-5">
<div>
<Zeile label="Orgnummer">{unit.org_number}</Zeile>
<Zeile label="Art">{unit.unit_type}</Zeile>
<Zeile label="Übergeordnet">
{kette.length > 0 ? kette.map((u) => u.name).join(" › ") : "—"}
</Zeile>
<Zeile label="Gültig ab">{unit.valid_from ? fmtDate(unit.valid_from) : "—"}</Zeile>
<Zeile label="Gültig bis">{unit.valid_to ? fmtDate(unit.valid_to) : "unbefristet"}</Zeile>
</div>
<div>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Besetzung</h3>
<Zeile label="Leitung">
{leitung ? (
<Link href={`/employees/${leitung.id}`} className="text-brand-700 hover:underline">
{fmtName(leitung.first_name, leitung.last_name)}
</Link>
) : (
<span className="font-semibold text-warning-text">vakant</span>
)}
</Zeile>
<Zeile label="Mitarbeitende">{belegschaft.length}</Zeile>
<Zeile label="Unbesetzte Planstellen">{offene.length}</Zeile>
</div>
<div>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Struktur</h3>
<Zeile label="Untergeordnete Einheiten">{kinder.length}</Zeile>
<Zeile label="Personen im gesamten Teilbaum">{imTeilbaum}</Zeile>
</div>
{kinder.length > 0 && (
<div>
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-ink-muted">Direkt darunter</h3>
<ul className="flex flex-col gap-1 text-sm text-ink-body">
{kinder.map((k) => (
<li key={k.id}>
<span className="tabular-nums text-ink-muted">{k.org_number}</span> · {k.name}{" "}
<span className="text-xs text-ink-muted">({k.unit_type})</span>
</li>
))}
</ul>
</div>
)}
{offene.length > 0 && (
<div>
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-ink-muted">Unbesetzt</h3>
<ul className="flex flex-col gap-1 text-sm text-ink-body">
{offene.map((v) => (
<li key={v.position_id}>
<span className="tabular-nums text-ink-muted">{v.position_number}</span> · {v.job_title}
{v.is_chief && <span className="text-xs text-ink-muted"> (Leitung)</span>}
</li>
))}
</ul>
{/* Kontierung, Tätigkeit und Gültigkeit einer Planstelle stehen auf
„Positionen"; von hier führt wenigstens der Weg dorthin. */}
<Link href="/positions" className="mt-1.5 inline-block text-xs font-semibold text-brand-700 hover:underline">
Auf Positionen bearbeiten
</Link>
</div>
)}
<div className="flex flex-wrap gap-2 border-t border-border pt-4">
<Button variant="secondary" size="sm" onClick={() => onAddUnit(unit.id)}>
<Plus className="h-4 w-4" />
Einheit darunter anlegen
</Button>
{entfernbar && (
<Button variant="secondary" size="sm" onClick={() => onDeleteUnit(unit.id)}>
<Trash2 className="h-4 w-4" />
Einheit entfernen
</Button>
)}
</div>
</div>
</SlideOver>
);
}