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}
+
);
}
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 (
-
-
+
+ );
+}
+
+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 () => {