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.
242 lines
10 KiB
TypeScript
242 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { Plus, Trash2 } from "lucide-react";
|
||
import Link from "next/link";
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import { ladePlanstellenDerEinheit, type PlanstelleDerEinheit } from "@/actions/positions";
|
||
import { EditPositionModal } from "@/components/positions/EditPositionModal";
|
||
import type { UnitOption } from "@/components/positions/CreatePositionModal";
|
||
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,
|
||
unitOptions,
|
||
employees,
|
||
vacancies,
|
||
onAddUnit,
|
||
onDeleteUnit,
|
||
onClose,
|
||
}: {
|
||
unit: OrgUnitNode;
|
||
units: OrgUnitNode[];
|
||
/** Für den Änderungsdialog: derselbe Baum, nur in seiner Form. */
|
||
unitOptions: UnitOption[];
|
||
employees: OrgEmployee[];
|
||
vacancies: OrgVacancy[];
|
||
onAddUnit: (unitId: string) => void;
|
||
onDeleteUnit: (unitId: string) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
// Erst beim Öffnen geladen, und nur für diese eine Einheit — siehe den Grund
|
||
// bei ladePlanstellenDerEinheit.
|
||
const [stellen, setStellen] = useState<PlanstelleDerEinheit[] | null>(null);
|
||
const [kostenstellen, setKostenstellen] = useState<{ id: string; code: string; name: string }[]>([]);
|
||
const [heute, setHeute] = useState("");
|
||
const [bearbeitet, setBearbeitet] = useState<PlanstelleDerEinheit | null>(null);
|
||
|
||
const laden = useCallback(async () => {
|
||
const daten = await ladePlanstellenDerEinheit(unit.id);
|
||
setStellen(daten.planstellen);
|
||
setKostenstellen(daten.kostenstellen);
|
||
setHeute(daten.heute);
|
||
}, [unit.id]);
|
||
|
||
useEffect(() => {
|
||
let verworfen = false;
|
||
// Der Nachtrag einer Antwort, die zu einer anderen Einheit gehört, wäre
|
||
// hier besonders tückisch: die Leiste sähe richtig aus und zeigte fremde
|
||
// Planstellen.
|
||
void ladePlanstellenDerEinheit(unit.id).then((daten) => {
|
||
if (verworfen) return;
|
||
setStellen(daten.planstellen);
|
||
setKostenstellen(daten.kostenstellen);
|
||
setHeute(daten.heute);
|
||
});
|
||
return () => {
|
||
verworfen = true;
|
||
};
|
||
}, [unit.id]);
|
||
|
||
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>
|
||
)}
|
||
|
||
<div>
|
||
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||
Planstellen{stellen ? ` (${stellen.length})` : ""}
|
||
</h3>
|
||
{stellen === null ? (
|
||
<p className="text-sm text-ink-muted">Lädt…</p>
|
||
) : stellen.length === 0 ? (
|
||
<p className="text-sm text-ink-muted">In dieser Einheit besteht keine Planstelle.</p>
|
||
) : (
|
||
<ul className="flex flex-col divide-y divide-border-subtle">
|
||
{stellen.map((s) => (
|
||
<li key={s.id}>
|
||
{/* Auch die besetzten sind anklickbar. Was sich daran ändern
|
||
lässt, entscheidet die Datenbankfunktion: Einheit und
|
||
Gültigkeitsende bleiben bei einer besetzten Stelle
|
||
gesperrt, Tätigkeit und Kontierung nicht. */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setBearbeitet(s)}
|
||
className="flex w-full flex-col items-start rounded py-1.5 text-left hover:bg-surface
|
||
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||
>
|
||
<span className="text-sm text-ink-body">
|
||
<span className="tabular-nums text-ink-muted">{s.position_number}</span> · {s.title}
|
||
{s.is_chief && <span className="text-xs text-ink-muted"> (Leitung)</span>}
|
||
</span>
|
||
<span className="text-xs text-ink-muted">
|
||
{s.besetztVon ?? <span className="font-semibold text-warning-text">unbesetzt</span>}
|
||
{s.kostenstelle ? ` · Kostenstelle ${s.kostenstelle.code}` : " · ohne Kostenstelle"}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<Link href="/positions" className="mt-1.5 inline-block text-xs font-semibold text-brand-700 hover:underline">
|
||
Alle unbesetzten Planstellen
|
||
</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>
|
||
|
||
{/* Derselbe Dialog wie auf der Seite Positionen. Ein zweiter, der
|
||
dasselbe tut, liefe beim ersten Zusatz auseinander. */}
|
||
{bearbeitet && (
|
||
<EditPositionModal
|
||
key={bearbeitet.id}
|
||
position={bearbeitet}
|
||
units={unitOptions}
|
||
kostenstellen={kostenstellen}
|
||
heute={heute}
|
||
onClose={() => {
|
||
setBearbeitet(null);
|
||
// Die Liste stammt aus dem Zustand dieser Leiste; ohne erneutes
|
||
// Laden stünde nach dem Umkontieren weiter die alte Kostenstelle
|
||
// da, und der Dialog daneben zeigte die neue.
|
||
void laden();
|
||
}}
|
||
/>
|
||
)}
|
||
</SlideOver>
|
||
);
|
||
}
|