diff --git a/components/orgchart/PrintChart.tsx b/components/orgchart/PrintChart.tsx index b60dc58..4d5f203 100644 --- a/components/orgchart/PrintChart.tsx +++ b/components/orgchart/PrintChart.tsx @@ -2,16 +2,16 @@ import { ChevronRight, Printer } from "lucide-react"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useCallback, 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. // -// Zwei Entscheidungen prägen das hier: +// Drei Entscheidungen prägen das hier: // -// **Erstens wird gefragt, bevor gedruckt wird.** Ein Organigramm mit 850 +// **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. @@ -21,26 +21,111 @@ import type { PrintModel, PrintPerson, PrintUnit } from "@/lib/orgchart-print"; // 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 — passt auf ein Blatt" }, - { wert: "abteilung", label: "bis Abteilung", hinweis: "eine Ebene tiefer" }, - { wert: "team", label: "bis Team", hinweis: "die vollständige Struktur, ohne Namen" }, - { wert: "personen", label: "mit Personen", hinweis: "jede Einheit mit ihrer Belegschaft" }, + { 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" }, ]; -const EBENE: Record = { Gesellschaft: 0, Bereich: 1, Abteilung: 2, Team: 3 }; - -const MAX_EBENE: Record = { bereich: 1, abteilung: 2, team: 3, personen: 3 }; - type Format = "a4" | "a3"; -const FORMATE: Record = { - a4: { label: "A4 quer", css: "A4 landscape", breite: "297mm" }, - a3: { label: "A3 quer", css: "A3 landscape", breite: "420mm" }, +// 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; + /** Nur auf der Übersicht: die gewählten Bereiche statt aller Kinder. */ + kinder?: PrintUnit[]; + /** Wie viele Ebenen unter der Wurzel gezeichnet werden. */ + ebenen: number; + mitPersonen: boolean; +}; + +function seitenBauen(model: PrintModel, bereiche: PrintUnit[], tiefe: Tiefe, asOf: string): Seite[] { + const stand = `Stand ${fmtDate(asOf)}`; + const seiten: Seite[] = []; + + if (model.root) { + seiten.push({ + key: "uebersicht", + titel: model.root.name, + unterzeile: `Organigramm · ${stand} · ${model.totals.people} Personen`, + wurzel: model.root, + kinder: bereiche, + // Bei „bis Abteilung" ist die Übersicht das ganze Ergebnis. Sonst folgen + // Detailblätter, und dann bleibt sie eine reine Bereichsübersicht. + ebenen: tiefe === "abteilung" ? 2 : 1, + 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"); @@ -48,8 +133,28 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st const [schritt, setSchritt] = useState<"auswahl" | "vorschau">("auswahl"); const bereiche = useMemo(() => model.divisions.filter((d) => gewaehlt.has(d.id)), [model.divisions, gewaehlt]); - const mitPersonen = tiefe === "personen"; - const seiten = 1 + (mitPersonen || tiefe === "team" ? bereiche.length : 0); + 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) => + 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) => { @@ -68,35 +173,49 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st html, body { background: #fff; } .nur-bildschirm { display: none !important; } .blatt { - box-shadow: none !important; border: 0 !important; margin: 0 !important; - width: auto !important; max-width: none !important; padding: 0 !important; + box-shadow: none !important; border: 0 !important; border-radius: 0 !important; + margin: 0 !important; width: auto !important; padding: 0 !important; break-after: page; } .blatt:last-child { break-after: auto; } - .nicht-trennen { break-inside: avoid; } + /* Die Satzfläche behält ihre Masse — der Massstab wurde für genau + diese berechnet. Nur der Rand fällt weg, den setzt @page. */ + .satz { margin: 0 !important; } } - /* Der bewährte CSS-Baum: jedes Kind zieht zwei Viertelbögen nach oben, - das erste und das letzte lassen ihre Aussenseite weg. Zusammen - ergibt das eine durchgehende Querlinie ohne ein einziges - zusätzliches Element. */ - .baum, .baum ul { display: flex; justify-content: center; gap: 0; list-style: none; margin: 0; padding: 0; } - .baum li { position: relative; padding: 18px 6px 0; text-align: center; } - .baum li::before, .baum li::after { + + /* Eine Reihe: waagrechte Geschwister mit Winkeln nach oben. Das erste + und das letzte Kind lassen ihre Aussenseite weg — zusammen ergibt + das eine durchgehende Querlinie ohne ein einziges Hilfselement. */ + ul.reihe { + position: relative; display: flex; justify-content: center; + list-style: none; margin: 0; padding: 18px 0 0; + } + ul.reihe::before { + content: ""; position: absolute; top: 0; left: 50%; + height: 18px; border-left: 1px solid var(--color-border); + } + ul.reihe > li { position: relative; width: var(--spalte); padding: 18px 6px 0; } + ul.reihe > li::before, ul.reihe > li::after { content: ""; position: absolute; top: 0; width: 50%; height: 18px; border-top: 1px solid var(--color-border); } - .baum li::before { right: 50%; } - .baum li::after { left: 50%; border-left: 1px solid var(--color-border); } - .baum li:only-child::before, .baum li:only-child::after { display: none; } - .baum li:only-child { padding-top: 18px; } - .baum li:first-child::before, .baum li:last-child::after { border-top: 0; } - .baum li:last-child::before { border-right: 1px solid var(--color-border); } - .baum > li { padding-top: 0; } - .baum > li::before, .baum > li::after { display: none; } - .baum ul { position: relative; padding-top: 18px; } - .baum ul::before { - content: ""; position: absolute; top: 0; left: 50%; - width: 0; height: 18px; border-left: 1px solid var(--color-border); + ul.reihe > li::before { right: 50%; } + ul.reihe > li::after { left: 50%; border-left: 1px solid var(--color-border); } + ul.reihe > li:first-child::before { border-top: 0; } + ul.reihe > li:last-child::after { border-top: 0; } + + /* Ein Stapel: längs an einer Linie, mit Winkel in jeden Kasten. Kostet + Höhe statt Breite — und Höhe ist auf einem Querformat übrig. */ + ul.stapel { list-style: none; margin: 0; padding: 0; text-align: left; } + ul.stapel > li { position: relative; padding: 8px 0 0 20px; } + ul.stapel > li::before { + content: ""; position: absolute; left: 9px; top: 0; bottom: 0; + border-left: 1px solid var(--color-border); + } + ul.stapel > li:last-child::before { bottom: auto; height: 22px; } + ul.stapel > li::after { + content: ""; position: absolute; left: 9px; top: 22px; width: 11px; + border-top: 1px solid var(--color-border); } `} @@ -189,7 +308,7 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st Kein Bereich gewählt — es bliebe nur die Übersicht. ) : ( <> - Ergibt {seiten} {seiten === 1 ? "Seite" : "Seiten"} + Ergibt {seiten.length} {seiten.length === 1 ? "Seite" : "Seiten"} )}

@@ -211,7 +330,7 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st <>

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

@@ -236,37 +355,31 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
+ {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“.

- - - - - - {(tiefe === "team" || mitPersonen) && - bereiche.map((b, i) => ( - - - - - ))} + {seiten.map((seite, i) => ( + + + meldeMassstab(seite.key, f)}> + + + + ))}
)} @@ -274,20 +387,20 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st ); } -function Blatt({ children, breite }: { children: React.ReactNode; breite: string }) { +function Blatt({ children, format }: { children: React.ReactNode; format: Format }) { + const { blatt, satzBreite, satzHoehe } = FORMATE[format]; return ( -
- {children} +
+
+ {children} +
); } function Kopf({ titel, unterzeile, seite }: { titel: string; unterzeile: string; seite: string }) { return ( -
+

{titel}

{unterzeile}

@@ -297,67 +410,139 @@ function Kopf({ titel, unterzeile, seite }: { titel: string; unterzeile: string; ); } -function Baum({ - wurzel, - bereiche, - maxEbene, - mitPersonen, +/** + * 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, }: { - wurzel: PrintUnit | null; - /** Auf der Übersicht nur die gewählten Bereiche unter die Wurzel hängen. */ - bereiche: PrintUnit[] | null; - maxEbene: number; - mitPersonen: boolean; + kennung: string; + onFaktor: (faktor: number) => void; + children: React.ReactNode; }) { - if (!wurzel) return

Keine Organisationsstruktur vorhanden.

; - const kinder = bereiche ?? wurzel.children; + const rahmen = useRef(null); + const inhalt = useRef(null); + const [faktor, setFaktor] = useState(1); + const [versatz, setVersatz] = useState(0); + + 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)); + onFaktor(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, onFaktor]); + return ( -
    - +
    +
    + {children} +
    +
    + ); +} + +function Chart({ seite }: { seite: Seite }) { + const kinder = seite.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) => ( +
    • + + +
    • + ))}
    ); } -function Knoten({ - unit, - kinder, - maxEbene, - mitPersonen, -}: { - unit: PrintUnit; - kinder?: PrintUnit[]; - maxEbene: number; - mitPersonen: boolean; -}) { - const naechste = kinder ?? unit.children; - const zeigeKinder = EBENE[unit.unitType] < maxEbene && naechste.length > 0; - - return ( -
  • - - {zeigeKinder && ( -
      - {naechste.map((k) => ( - - ))} -
    - )} -
  • - ); -} +const KOPFFARBE: Record = { + // Nach unten hin blasser: so bleibt die Rangfolge auch auf dem + // Schwarzweissdrucker sichtbar, auf dem die meisten Ausdrucke landen. + Gesellschaft: "bg-brand-500 text-white", + Bereich: "bg-brand-100 text-ink", + Abteilung: "bg-brand-50 text-ink", + Team: "bg-surface text-ink", +}; function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }) { - const ebene = EBENE[unit.unitType]; - // Je höher die Einheit, desto kräftiger — so ist die Rangfolge auch in - // Graustufen erkennbar, und viele Ausdrucke sind schwarzweiss. - const kopf = - ebene === 0 ? "bg-brand-500 text-white" : ebene === 1 ? "bg-brand-100 text-ink" : ebene === 2 ? "bg-brand-50 text-ink" : "bg-surface text-ink"; + 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}

    @@ -370,20 +555,22 @@ function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean } )} {mitPersonen && unit.members.length > 0 && ( -
    +
    {unit.members.map((p) => ( - +
    + +
    ))}
    )} + {mitPersonen && - unit.vacancies - .filter((v) => !v.isChief) - .map((v) => ( -

    - offen · {v.jobTitle} -

    - ))} + offen.map((v) => ( +

    + offen · {v.jobTitle} +

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

    {unit.members.length} {unit.members.length === 1 ? "Person" : "Personen"} diff --git a/tests/components/PrintChart.test.tsx b/tests/components/PrintChart.test.tsx index e46f393..f8a7410 100644 --- a/tests/components/PrintChart.test.tsx +++ b/tests/components/PrintChart.test.tsx @@ -122,14 +122,27 @@ describe("PrintChart", () => { renderChart(); await zurVorschau(user); - // Der Bereichsname steht auf der Seite zweimal — in der Kopfzeile und im - // Wurzelkasten des Baums. Geprüft wird der Baum. - const baum = blaetter()[1].querySelector("ul.baum")!; - const fertigung = within(baum).getByText("Fertigung"); - const bereichsKnoten = within(baum).getByText("Produktion").closest("li")!; - expect(bereichsKnoten.contains(fertigung)).toBe(true); - // Und das Team wiederum unter der Abteilung. - expect(fertigung.closest("li")!.contains(within(baum).getByText("Schicht A"))).toBe(true); + // Die Abteilungen hängen in einer Reihe unter dem Bereich … + const seite = blaetter()[1]; + const reihe = seite.querySelector("ul.reihe")!; + const fertigung = within(reihe).getByText("Fertigung"); + // … und das Team längs unter seiner Abteilung. + const abteilungsKnoten = fertigung.closest("li")!; + const stapel = abteilungsKnoten.querySelector("ul.stapel")!; + expect(within(stapel).getByText("Schicht A")).toBeInTheDocument(); + }); + + it("breitet nur eine Ebene waagrecht aus — der Rest hängt längs", async () => { + const user = userEvent.setup(); + renderChart(); + await zurVorschau(user); + + // Der Grund: zwei waagrechte Ebenen multiplizieren sich in der Breite und + // laufen über den Blattrand. Je Blatt darf es genau eine Reihe geben. + for (const seite of blaetter()) { + expect(seite.querySelectorAll("ul.reihe")).toHaveLength(1); + } + expect(blaetter()[1].querySelectorAll("ul.stapel").length).toBeGreaterThan(0); }); it("nennt Namen erst bei „mit Personen“", async () => { @@ -143,6 +156,10 @@ describe("PrintChart", () => { await user.click(screen.getByRole("button", { name: /mit Personen/ })); expect(screen.getByText("Test Aigner")).toBeInTheDocument(); expect(screen.getByText(/offen · Disposition/)).toBeInTheDocument(); + // Mit Namen bekommt jede Abteilung ihr eigenes Blatt — ein ganzer Bereich + // mit allen Namen wäre auf einem Blatt nicht mehr lesbar. + expect(blaetter()).toHaveLength(3); + expect(screen.getByText("Produktion · Fertigung")).toBeInTheDocument(); }); it("meldet unbesetzte Leitungen statt sie zu verschweigen", async () => {