"use client"; import { ChevronRight, Printer } from "lucide-react"; import Link from "next/link"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/Button"; import { fmtDate } from "@/lib/format"; import type { PrintModel, PrintPerson, PrintUnit } from "@/lib/orgchart-print"; // Auswahl, dann Vorschau, dann Papier. // // Drei Entscheidungen prägen das hier: // // **Erstens wird gefragt, bevor gedruckt wird.** Ein Organigramm mit 810 // Personen auf Papier ist fast nie das, was jemand braucht — meist geht es um // einen Bereich, oder um die oberen zwei Ebenen für eine Besprechung. Wer // alles bekommt, druckt vierzig Blätter und wirft achtunddreissig weg. // // **Zweitens wird hierarchisch gezeichnet, nicht aufgelistet.** Kästen mit // Verbindungslinien zeigen die Über- und Unterordnung; eine Spaltenliste // zeigt sie nicht, egal wie sauber sie gesetzt ist. Die Linien entstehen aus // Rahmen von Pseudo-Elementen — damit sind sie im PDF Vektoren und bleiben // beim Hineinzoomen scharf. // // **Drittens breitet sich nur eine Ebene waagrecht aus.** Ein Baum, der auf // jeder Stufe in die Breite geht, wächst exponentiell: sechs Bereiche mit je // drei Abteilungen sind achtzehn Kästen nebeneinander, und quer über ein A4 // bleiben davon zwei Millimeter je Kasten — die erste Fassung lief genau // deshalb über den Blattrand hinaus. Jetzt stehen die Bereiche in einer // Reihe, und alles darunter hängt längs an einer Linie: die Breite ist die // Zahl der Bereiche, unabhängig von der Tiefe. Was dann immer noch übersteht, // wird als Ganzes so weit verkleinert, bis es aufs Blatt passt. type Tiefe = "bereich" | "abteilung" | "team" | "personen"; const TIEFEN: { wert: Tiefe; label: string; hinweis: string }[] = [ { wert: "bereich", label: "Bereiche", hinweis: "Gesellschaft und Bereiche — ein Blatt" }, { wert: "abteilung", label: "bis Abteilung", hinweis: "eine Ebene tiefer, noch immer ein Blatt" }, { wert: "team", label: "bis Team", hinweis: "je Bereich ein Blatt, ohne Namen" }, { wert: "personen", label: "mit Personen", hinweis: "je Abteilung ein Blatt, mit allen Namen" }, ]; type Format = "a4" | "a3"; // Die Satzfläche ist die Blattgrösse abzüglich der Ränder aus @page, ein // wenig knapper gerechnet. Die Vorschau bekommt genau diese Masse fest // zugewiesen — dadurch ist das, was am Bildschirm zu sehen ist, dasselbe, was // aufs Papier passt, und nicht bloss ungefähr. const FORMATE: Record = { a4: { label: "A4 quer", css: "A4 landscape", blatt: "297mm", satzBreite: "268mm", satzHoehe: "182mm" }, a3: { label: "A3 quer", css: "A3 landscape", blatt: "420mm", satzBreite: "390mm", satzHoehe: "269mm" }, }; /** Unter diesem Massstab wird es auf A4 mühsam — dann lieber A3. */ const LESBAR = 0.55; type Seite = { key: string; titel: string; unterzeile: string; wurzel: PrintUnit; /** Wie viele Ebenen unter der Wurzel gezeichnet werden. */ ebenen: number; mitPersonen: boolean; }; /** * Die Wurzel, beschnitten auf die Wege zu den gewählten Bereichen. * * Die Übersicht hängte die gewählten Bereiche unmittelbar unter die * Gesellschaft. Das war richtig, solange sie dort auch hingen. Bei Manner * liegt „CEO" dazwischen: die Übersicht hätte ihn stillschweigend * übersprungen und die sechs C-Level direkt an die AG gehängt — ein * Organigramm, das eine Ebene unterschlägt, ist schlimmer als keines. * * Gezeichnet wird deshalb der echte Weg von oben, nur ohne die Äste, in denen * kein gewählter Bereich liegt. Damit wirkt das Ab- und Anwählen weiterhin * bis in die Übersicht hinein. */ function beschneiden(unit: PrintUnit, ziele: Set, mitKindern: boolean): PrintUnit | null { if (ziele.has(unit.id)) { // Am Ziel hört der Weg auf. Eine Ebene tiefer nur bei „bis Abteilung", // wo die Übersicht das ganze Ergebnis ist. return { ...unit, children: mitKindern ? unit.children.map((k) => ({ ...k, children: [] })) : [] }; } const kinder = unit.children .map((k) => beschneiden(k, ziele, mitKindern)) .filter((k): k is PrintUnit => k !== null); return kinder.length > 0 ? { ...unit, children: kinder } : null; } /** Wie viele Ebenen unter dieser Einheit noch folgen. */ function ebenenVon(unit: PrintUnit): number { return unit.children.length === 0 ? 0 : 1 + Math.max(...unit.children.map(ebenenVon)); } function seitenBauen(model: PrintModel, bereiche: PrintUnit[], tiefe: Tiefe, asOf: string): Seite[] { const stand = `Stand ${fmtDate(asOf)}`; const seiten: Seite[] = []; if (model.root) { const ziele = new Set(bereiche.map((b) => b.id)); // Ist nichts gewählt, bleibt die Gesellschaft allein stehen. const wurzel = beschneiden(model.root, ziele, tiefe === "abteilung") ?? { ...model.root, children: [] }; seiten.push({ key: "uebersicht", titel: model.root.name, unterzeile: `Organigramm · ${stand} · ${model.totals.people} Personen`, wurzel, // So tief, wie der beschnittene Baum reicht — nicht nach einer festen // Zahl von Ebenen, denn wie viele zwischen Gesellschaft und Bereich // liegen, hängt am Kunden. ebenen: ebenenVon(wurzel), mitPersonen: false, }); } if (tiefe === "team") { for (const b of bereiche) { seiten.push({ key: b.id, titel: b.name, unterzeile: `${b.totalPeople} Personen · ${stand}`, wurzel: b, ebenen: 2, mitPersonen: false, }); } } if (tiefe === "personen") { for (const b of bereiche) { // Wer unmittelbar im Bereich sitzt, hätte sonst kein Blatt — die // Leitung steht auf der Übersicht, die übrigen nirgends. if (b.members.length > 0) { seiten.push({ key: `${b.id}-direkt`, titel: b.name, unterzeile: `Bereichsleitung und Stab · ${stand}`, wurzel: b, ebenen: 0, mitPersonen: true, }); } for (const a of b.children) { seiten.push({ key: a.id, titel: `${b.name} · ${a.name}`, unterzeile: `${a.totalPeople} Personen · ${stand}`, wurzel: a, ebenen: 1, mitPersonen: true, }); } } } return seiten; } export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: string; today: string }) { const [format, setFormat] = useState("a4"); const [tiefe, setTiefe] = useState("team"); const [gewaehlt, setGewaehlt] = useState>(() => new Set(model.divisions.map((d) => d.id))); const [schritt, setSchritt] = useState<"auswahl" | "vorschau">("auswahl"); const bereiche = useMemo(() => model.divisions.filter((d) => gewaehlt.has(d.id)), [model.divisions, gewaehlt]); const seiten = useMemo(() => seitenBauen(model, bereiche, tiefe, asOf), [model, bereiche, tiefe, asOf]); // Der kleinste Massstab aller Blätter — daran hängt der Hinweis auf A3. // // Die gemeldeten Werte tragen die Auswahl mit, für die sie gemessen wurden: // ändert sie sich, sind die alten Werte nicht mehr gültig und werden // verworfen, sobald das erste Blatt der neuen Auswahl meldet. Das erspart // einen Effekt, der beim Wechsel aufräumt — und damit eine Renderrunde, // in der noch der Hinweis der vorherigen Auswahl stünde. const signatur = `${format}|${tiefe}|${[...gewaehlt].sort().join(",")}`; const [gemessen, setGemessen] = useState<{ signatur: string; werte: Record }>({ signatur, werte: {} }); const meldeMassstab = useCallback( (key: string, faktor: number) => { setGemessen((prev) => { // Unverändert heisst unverändert: denselben Zustand zurückgeben, nicht // ein gleich aussehendes neues Objekt. Ein neues Objekt zählt für React // als Änderung und löst ein Rendern aus — und da das Rendern die // Messung anstösst, die wieder hier landet, dreht sich das ohne Ende. // Genau daran ist die Vorschau eingefroren. if (prev.signatur === signatur && prev.werte[key] === faktor) return prev; return prev.signatur === signatur ? { signatur, werte: { ...prev.werte, [key]: faktor } } : { signatur, werte: { [key]: faktor } }; }); }, [signatur] ); const engste = Math.min(1, ...Object.values(gemessen.signatur === signatur ? gemessen.werte : {})); function umschalten(id: string) { setGewaehlt((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } return (
{/* ── Auswahl ─────────────────────────────────────────── */}

Was soll aufs Papier?

Das vollständige Organigramm sind {model.totals.people} Personen. Gedruckt wird selten alles — hier steht, wie tief und welche Bereiche.

Stand {fmtDate(asOf)} {asOf !== today && " · abweichender Stichtag"}

Tiefe

{TIEFEN.map((t) => ( ))}

Bereiche ({gewaehlt.size} von {model.divisions.length})

{model.divisions.map((d) => ( ))}

{gewaehlt.size === 0 ? ( Kein Bereich gewählt — es bliebe nur die Übersicht. ) : ( <> Ergibt {seiten.length} {seiten.length === 1 ? "Seite" : "Seiten"} )}

Zurück
{schritt === "vorschau" && ( <>

{seiten.length} {seiten.length === 1 ? "Seite" : "Seiten"} · so kommt es aus dem Drucker

{(Object.keys(FORMATE) as Format[]).map((f) => ( ))}
{format === "a4" && engste < LESBAR && (

Ein Blatt musste auf {Math.round(engste * 100)} % verkleinert werden, damit es passt — auf A4 kaum noch lesbar. A3 wählen oder weniger Bereiche.

)}

Im Druckdialog „Als PDF speichern“ wählen. Hintergrundgrafiken müssen dort eingeschaltet sein, sonst fehlen die farbigen Kopfzeilen — in Chrome unter „Weitere Einstellungen“.

{seiten.map((seite, i) => ( meldeMassstab(seite.key, f)}> ))}
)}
); } function Blatt({ children, format }: { children: React.ReactNode; format: Format }) { const { blatt, satzBreite, satzHoehe } = FORMATE[format]; return (
{children}
); } function Kopf({ titel, unterzeile, seite }: { titel: string; unterzeile: string; seite: string }) { return (

{titel}

{unterzeile}

{seite}

); } /** * Verkleinert den Inhalt so weit, dass er in die Satzfläche passt. * * Ein Organigramm hat keine Grösse, die man vorher kennt — sie hängt daran, * wie viele Bereiche jemand angehakt hat und wie tief. Statt zu raten wird * gemessen: die natürliche Grösse steht im Layout, die verfügbare auch, und * `transform: scale` ändert nur die Darstellung, nicht das Layout. Damit ist * die Messung stabil und läuft nicht gegen sich selbst. */ function Skaliert({ kennung, onFaktor, children, }: { kennung: string; onFaktor: (faktor: number) => void; children: React.ReactNode; }) { const rahmen = useRef(null); const inhalt = useRef(null); const [faktor, setFaktor] = useState(1); const [versatz, setVersatz] = useState(0); // Die Meldefunktion wird beim Aufruf frisch erzeugt und ist daher bei jedem // Rendern eine andere. Stünde sie in den Abhängigkeiten, liefe der Effekt // nach jedem Rendern erneut — samt Messung, die das nächste Rendern // auslöst. Über eine Referenz bleibt der aktuelle Stand erreichbar, ohne // dass der Effekt daran hängt. const melden = useRef(onFaktor); useEffect(() => { melden.current = onFaktor; }); useLayoutEffect(() => { const aussen = rahmen.current; const innen = inhalt.current; if (!aussen || !innen) return; function messen() { if (!aussen || !innen) return; const breite = aussen.clientWidth; const hoehe = aussen.clientHeight; const noetigeBreite = innen.offsetWidth; const noetigeHoehe = innen.offsetHeight; if (!breite || !hoehe || !noetigeBreite || !noetigeHoehe) return; const f = Math.min(1, breite / noetigeBreite, hoehe / noetigeHoehe); setFaktor(f); // Waagrecht mittig: die Verkleinerung geht von links oben aus, sonst // klebt ein schmales Diagramm am linken Blattrand. setVersatz(Math.max(0, (breite - noetigeBreite * f) / 2)); melden.current(f); } messen(); // Schriften kommen nach dem ersten Layout — bis dahin sind alle Kästen in // der Ersatzschrift gesetzt und damit anders breit. void document.fonts?.ready.then(messen); const beobachter = new ResizeObserver(messen); beobachter.observe(aussen); return () => beobachter.disconnect(); }, [kennung]); return (
{children}
); } function Chart({ seite }: { seite: Seite }) { const kinder = seite.wurzel.children; const zeigeKinder = seite.ebenen > 0 && kinder.length > 0; return ( // Feste Spaltenbreite: dadurch sitzt jeder Kasten mittig unter seiner // Verbindungslinie, und die Gesamtbreite ist die Zahl der Spalten mal // dieser Breite — berechenbar, statt vom längsten Namen abzuhängen.
{zeigeKinder && (
    {kinder.map((k) => (
  • ))}
)}
); } function Stapel({ units, ebenen, mitPersonen }: { units: PrintUnit[]; ebenen: number; mitPersonen: boolean }) { if (ebenen <= 0 || units.length === 0) return null; return (
    {units.map((u) => (
  • ))}
); } const KOPFFARBE: Record = { // Nach unten hin blasser: so bleibt die Rangfolge auch auf dem // Schwarzweissdrucker sichtbar, auf dem die meisten Ausdrucke landen. // Vier Stufen einer Farbe statt drei plus der Seitenflaeche: so ist es // eine Reihe und nicht eine Reihe mit einem Fremdkoerper am Ende. Gesellschaft: "bg-brand-500 text-white", Bereich: "bg-brand-200 text-ink", Abteilung: "bg-brand-100 text-ink", Team: "bg-brand-50 text-ink", }; function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }) { const offen = unit.vacancies.filter((v) => !v.isChief); // Lange Namenslisten in zwei Spalten: sonst wächst der Kasten in die Höhe, // und die Höhe ist es, die den Massstab drückt. const zweispaltig = mitPersonen && unit.members.length > 8; return (

{unit.name}

{unit.unitType} · {unit.totalPeople}

{/* Die Gesellschaft bleibt ohne Vermerk: sie wird nicht geführt, sondern ist das Ganze. „Leitung unbesetzt" las sich dort wie ein Mangel, den jemand beheben müsste. Überall sonst ist es genau das und gehört hin. */} {unit.chief ? ( ) : unit.unitType !== "Gesellschaft" ? (

Leitung unbesetzt

) : null} {mitPersonen && unit.members.length > 0 && (
{unit.members.map((p) => (
))}
)} {mitPersonen && offen.map((v) => (

offen · {v.jobTitle}

))} {!mitPersonen && unit.members.length > 0 && (

{unit.members.length} {unit.members.length === 1 ? "Person" : "Personen"}

)}
); } function PersonZeile({ p, leitung }: { p: PrintPerson; leitung?: boolean }) { return (

{p.name} {p.absent && · abwesend}
{p.jobTitle}

); }