Klick auf eine Einheit zeigt ihre Angaben
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m56s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m7s

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.
This commit is contained in:
2026-09-28 21:27:36 +02:00
parent a3dde61ec1
commit ab50c21b8a
7 changed files with 259 additions and 7 deletions

View File

@@ -22,7 +22,13 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro
const { org, units } = await withUser(await currentUserId(), async (tx) => {
const [org, units] = await Promise.all([
loadOrgAsOf(tx, asOf),
tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(),
tx
.selectFrom("org_units")
// valid_from/valid_to nur hier: die Angaben zur Einheit zeigen sie,
// der Druck braucht sie nicht.
.select(["id", "org_number", "name", "parent_id", "unit_type", "valid_from", "valid_to"])
.orderBy("org_number")
.execute(),
]);
return { org, units };
});

View File

@@ -42,6 +42,7 @@ export type GraphOrgChartProps = {
/** Nur die Struktursicht reicht sie herein; in der Mitarbeitersicht gibt es keine Einheiten. */
onAddUnit?: (unitId: string) => void;
onDeleteUnit?: (unitId: string) => void;
onSelectUnit?: (unitId: string) => void;
};
export function GraphOrgChart(props: GraphOrgChartProps) {
@@ -52,7 +53,15 @@ export function GraphOrgChart(props: GraphOrgChartProps) {
);
}
function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds, onAddUnit, onDeleteUnit }: GraphOrgChartProps) {
function GraphOrgChartInner({
tree,
isExpanded,
onToggle,
matchedIds,
onAddUnit,
onDeleteUnit,
onSelectUnit,
}: GraphOrgChartProps) {
const { visibleNodes, visibleEdges } = useMemo(() => collectVisible(tree, isExpanded), [tree, isExpanded]);
const { rfNodes, rfEdges } = useMemo(() => {
@@ -70,6 +79,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds, onAddUnit,
onToggle,
onAddUnit,
onDeleteUnit,
onSelectUnit,
},
}));
const rfEdges: Edge[] = visibleEdges.map((e) => ({
@@ -81,7 +91,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds, onAddUnit,
style: { stroke: "#e3cddb", strokeWidth: 1.5 },
}));
return { rfNodes, rfEdges };
}, [visibleNodes, visibleEdges, isExpanded, onToggle, onAddUnit, onDeleteUnit]);
}, [visibleNodes, visibleEdges, isExpanded, onToggle, onAddUnit, onDeleteUnit, onSelectUnit]);
const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges);

View File

@@ -17,6 +17,8 @@ export type OrgChartNodeData = {
/** 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">;
@@ -45,7 +47,7 @@ const KIND_SHELL: Record<ChartNodeKind, string> = {
// 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 } = data;
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];
@@ -161,6 +163,18 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
<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>
)}

View File

@@ -0,0 +1,164 @@
"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>
);
}

View File

@@ -9,6 +9,7 @@ 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";
@@ -54,15 +55,24 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
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 einheit = units.find((u) => u.id === unitId);
const betroffen = units.find((u) => u.id === unitId);
const result = await deleteOrgUnit(unitId);
if (result.success) {
showToast(`${einheit ? `${einheit.org_number} · ${einheit.name}` : "Einheit"} entfernt.`);
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");
@@ -92,6 +102,7 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
onToggle={toggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
onSelectUnit={onSelectUnit}
/>
) : (
<div className="rounded border border-border bg-white p-4">
@@ -104,12 +115,25 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
onToggle={toggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
onSelectUnit={onSelectUnit}
/>
))}
</div>
)}
{eltern && <CreateOrgUnitModal parent={eltern} units={units} onClose={() => setAnlegenUnter(null)} />}
{einheit && (
<OrgUnitPanel
unit={einheit}
units={units}
employees={employees}
vacancies={vacancies}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
onClose={() => setGewaehlt(null)}
/>
)}
</div>
);
}
@@ -121,6 +145,7 @@ function ListNode({
onToggle,
onAddUnit,
onDeleteUnit,
onSelectUnit,
}: {
node: ChartNode;
depth: number;
@@ -128,6 +153,7 @@ function ListNode({
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);
@@ -158,6 +184,16 @@ function ListNode({
>
{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>
)}
@@ -204,6 +240,7 @@ function ListNode({
onToggle={onToggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
onSelectUnit={onSelectUnit}
/>
))}
</div>

View File

@@ -38,6 +38,13 @@ export type OrgUnitNode = {
name: string;
parent_id: string | null;
unit_type: "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
/**
* Nur die Struktursicht liest sie, für die Angaben zur Einheit. Der Druck
* kommt ohne sie aus und lädt sie deshalb auch nicht — optional statt
* überall mitgeschleppt.
*/
valid_from?: string;
valid_to?: string | null;
};
// Generic tree shape both EmployeeTree and PositionTree map their own data

View File

@@ -1,4 +1,5 @@
import { render as rtlRender, screen } from "@testing-library/react";
import { render as rtlRender, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { buildUnitTree, PositionTree } from "@/components/orgchart/PositionTree";
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "@/components/orgchart/types";
@@ -90,4 +91,17 @@ describe("PositionTree", () => {
render(<PositionTree employees={EMPLOYEES} units={[...UNITS, leer]} vacancies={VACANCIES} />);
expect(screen.getByRole("button", { name: /Einheit .*OE-0411.* entfernen/ })).toBeInTheDocument();
});
it("öffnet beim Klick auf eine Einheit deren Angaben", async () => {
// Der Klick auf die Karte war lange ohne Wirkung — die naheliegendste
// Geste in dieser Ansicht, und sie antwortete nicht.
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
await userEvent.click(screen.getByRole("button", { name: /Angaben zu .*Produktion/ }));
const panel = await screen.findByRole("dialog");
expect(within(panel).getByText("OE-0010 · Bereich")).toBeInTheDocument();
// Eine Person, keine Leitung, eine offene Planstelle — genau der Bestand
// aus den Fixtures.
expect(within(panel).getByText("vakant")).toBeInTheDocument();
});
});