Keep the org chart inside the page it is printed on
Six divisions, each with its departments beside it, ran off the edge of the sheet. The cause was structural, not cosmetic: every level spread horizontally, so width multiplied with depth. Six divisions times three departments is eighteen boxes across a landscape A4 — about two millimetres each, if they had fitted at all, which they did not. They overlapped and were clipped at the margin. Now only one level spreads sideways. The divisions stand in a row and everything below them hangs lengthwise off a vertical line, so width is the number of divisions and nothing else. Depth costs height instead, and on a landscape page height is what there is to spare. What still overhangs is scaled down as a whole. The sheet in the preview now carries the print area's exact dimensions rather than growing with its contents, so the fit is measured against the real page: what you see is what the printer gets. If a sheet has to shrink below 55% to fit, it says so and points at A3, instead of quietly producing something nobody can read. With names switched on, each department gets its own sheet — a whole division with every name was never going to be legible on one page — and long name lists set in two columns so the box grows sideways rather than pushing the scale down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<PrintUnit["unitType"], number> = { Gesellschaft: 0, Bereich: 1, Abteilung: 2, Team: 3 };
|
||||
|
||||
const MAX_EBENE: Record<Tiefe, number> = { bereich: 1, abteilung: 2, team: 3, personen: 3 };
|
||||
|
||||
type Format = "a4" | "a3";
|
||||
const FORMATE: Record<Format, { label: string; css: string; breite: string }> = {
|
||||
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<Format, { label: string; css: string; blatt: string; satzBreite: string; satzHoehe: string }> = {
|
||||
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<Format>("a4");
|
||||
const [tiefe, setTiefe] = useState<Tiefe>("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<string, number> }>({ 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);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
@@ -189,7 +308,7 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
|
||||
<span className="text-danger-text">Kein Bereich gewählt — es bliebe nur die Übersicht.</span>
|
||||
) : (
|
||||
<>
|
||||
Ergibt <strong className="text-ink">{seiten}</strong> {seiten === 1 ? "Seite" : "Seiten"}
|
||||
Ergibt <strong className="text-ink">{seiten.length}</strong> {seiten.length === 1 ? "Seite" : "Seiten"}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
@@ -211,7 +330,7 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
|
||||
<>
|
||||
<div className="nur-bildschirm flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-ink-muted">
|
||||
{seiten} {seiten === 1 ? "Seite" : "Seiten"} · so kommt es aus dem Drucker
|
||||
{seiten.length} {seiten.length === 1 ? "Seite" : "Seiten"} · so kommt es aus dem Drucker
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex rounded-md border border-border p-0.5">
|
||||
@@ -236,35 +355,29 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{format === "a4" && engste < LESBAR && (
|
||||
<p className="nur-bildschirm rounded border border-warning-text/30 bg-warning-bg px-3 py-2 text-sm text-warning-text">
|
||||
Ein Blatt musste auf {Math.round(engste * 100)} % verkleinert werden, damit es passt — auf A4 kaum noch
|
||||
lesbar. A3 wählen oder weniger Bereiche.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="nur-bildschirm max-w-prose text-xs leading-relaxed text-ink-muted">
|
||||
Im Druckdialog „Als PDF speichern“ wählen. Hintergrundgrafiken müssen dort eingeschaltet sein,
|
||||
sonst fehlen die farbigen Kopfzeilen — in Chrome unter „Weitere Einstellungen“.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
<Blatt breite={FORMATE[format].breite}>
|
||||
{seiten.map((seite, i) => (
|
||||
<Blatt key={seite.key} format={format}>
|
||||
<Kopf
|
||||
titel={model.root?.name ?? "Organigramm"}
|
||||
unterzeile={`Organigramm · Stand ${fmtDate(asOf)} · ${model.totals.people} Personen`}
|
||||
seite="Übersicht"
|
||||
titel={seite.titel}
|
||||
unterzeile={seite.unterzeile}
|
||||
seite={i === 0 ? "Übersicht" : `Seite ${i + 1} von ${seiten.length}`}
|
||||
/>
|
||||
<Baum
|
||||
wurzel={model.root}
|
||||
bereiche={bereiche}
|
||||
maxEbene={Math.min(MAX_EBENE[tiefe], 2)}
|
||||
mitPersonen={false}
|
||||
/>
|
||||
</Blatt>
|
||||
|
||||
{(tiefe === "team" || mitPersonen) &&
|
||||
bereiche.map((b, i) => (
|
||||
<Blatt key={b.id} breite={FORMATE[format].breite}>
|
||||
<Kopf
|
||||
titel={b.name}
|
||||
unterzeile={`${b.totalPeople} Personen · Stand ${fmtDate(asOf)}`}
|
||||
seite={`Seite ${i + 2} von ${seiten}`}
|
||||
/>
|
||||
<Baum wurzel={b} bereiche={null} maxEbene={3} mitPersonen={mitPersonen} />
|
||||
<Skaliert kennung={`${seite.key}|${format}|${tiefe}`} onFaktor={(f) => meldeMassstab(seite.key, f)}>
|
||||
<Chart seite={seite} />
|
||||
</Skaliert>
|
||||
</Blatt>
|
||||
))}
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div
|
||||
className="blatt w-full overflow-x-auto rounded border border-border bg-white p-8 shadow-[var(--shadow-card)]"
|
||||
style={{ maxWidth: breite }}
|
||||
>
|
||||
<div className="blatt rounded border border-border bg-white shadow-[var(--shadow-card)]" style={{ width: blatt }}>
|
||||
<div className="satz flex flex-col" style={{ width: satzBreite, height: satzHoehe, margin: "12mm 14mm 14mm" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Kopf({ titel, unterzeile, seite }: { titel: string; unterzeile: string; seite: string }) {
|
||||
return (
|
||||
<header className="mb-6 flex items-end justify-between border-b-2 border-brand-500 pb-2">
|
||||
<header className="mb-5 flex shrink-0 items-end justify-between border-b-2 border-brand-500 pb-2">
|
||||
<div>
|
||||
<h1 className="text-lg font-extrabold tracking-tight text-ink">{titel}</h1>
|
||||
<p className="mt-0.5 text-xs text-ink-muted">{unterzeile}</p>
|
||||
@@ -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 <p className="text-sm text-ink-muted">Keine Organisationsstruktur vorhanden.</p>;
|
||||
const kinder = bereiche ?? wurzel.children;
|
||||
const rahmen = useRef<HTMLDivElement>(null);
|
||||
const inhalt = useRef<HTMLDivElement>(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 (
|
||||
<ul className="baum">
|
||||
<Knoten unit={wurzel} kinder={kinder} maxEbene={maxEbene} mitPersonen={mitPersonen} />
|
||||
</ul>
|
||||
<div ref={rahmen} className="min-h-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
ref={inhalt}
|
||||
style={{
|
||||
width: "max-content",
|
||||
transform: `scale(${faktor})`,
|
||||
transformOrigin: "top left",
|
||||
marginLeft: versatz,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
function Chart({ seite }: { seite: Seite }) {
|
||||
const kinder = seite.kinder ?? seite.wurzel.children;
|
||||
const zeigeKinder = seite.ebenen > 0 && kinder.length > 0;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Kasten unit={unit} mitPersonen={mitPersonen} />
|
||||
// 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.
|
||||
<div className="inline-block text-center" style={{ ["--spalte" as string]: "13rem" }}>
|
||||
<div className="mx-auto w-[15rem]">
|
||||
<Kasten unit={seite.wurzel} mitPersonen={seite.mitPersonen} />
|
||||
</div>
|
||||
{zeigeKinder && (
|
||||
<ul>
|
||||
{naechste.map((k) => (
|
||||
<Knoten key={k.id} unit={k} maxEbene={maxEbene} mitPersonen={mitPersonen} />
|
||||
<ul className="reihe">
|
||||
{kinder.map((k) => (
|
||||
<li key={k.id}>
|
||||
<Kasten unit={k} mitPersonen={seite.mitPersonen} />
|
||||
<Stapel units={k.children} ebenen={seite.ebenen - 1} mitPersonen={seite.mitPersonen} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stapel({ units, ebenen, mitPersonen }: { units: PrintUnit[]; ebenen: number; mitPersonen: boolean }) {
|
||||
if (ebenen <= 0 || units.length === 0) return null;
|
||||
return (
|
||||
<ul className="stapel">
|
||||
{units.map((u) => (
|
||||
<li key={u.id}>
|
||||
<Kasten unit={u} mitPersonen={mitPersonen} />
|
||||
<Stapel units={u.children} ebenen={ebenen - 1} mitPersonen={mitPersonen} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const KOPFFARBE: Record<PrintUnit["unitType"], string> = {
|
||||
// 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 (
|
||||
<div className="nicht-trennen mx-auto inline-block min-w-[9rem] max-w-[13rem] rounded border border-border text-left align-top">
|
||||
<div className={`rounded-t px-2 py-1 ${kopf}`}>
|
||||
<div className="w-full overflow-hidden rounded border border-border text-left">
|
||||
<div className={`px-2 py-1 ${KOPFFARBE[unit.unitType]}`}>
|
||||
<p className="text-[11px] font-bold leading-tight">{unit.name}</p>
|
||||
<p className={`text-[9px] uppercase tracking-wide ${ebene === 0 ? "text-white/75" : "text-ink-muted"}`}>
|
||||
<p
|
||||
className={`text-[9px] uppercase tracking-wide ${
|
||||
unit.unitType === "Gesellschaft" ? "text-white/75" : "text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
{unit.unitType} · {unit.totalPeople}
|
||||
</p>
|
||||
</div>
|
||||
@@ -370,20 +555,22 @@ function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }
|
||||
)}
|
||||
|
||||
{mitPersonen && unit.members.length > 0 && (
|
||||
<div className="mt-1 border-t border-border-subtle pt-1">
|
||||
<div className={`mt-1 border-t border-border-subtle pt-1 ${zweispaltig ? "columns-2 gap-2" : ""}`}>
|
||||
{unit.members.map((p) => (
|
||||
<PersonZeile key={p.id} p={p} />
|
||||
<div key={p.id} className="break-inside-avoid">
|
||||
<PersonZeile p={p} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mitPersonen &&
|
||||
unit.vacancies
|
||||
.filter((v) => !v.isChief)
|
||||
.map((v) => (
|
||||
offen.map((v) => (
|
||||
<p key={v.positionNumber} className="text-[10px] italic text-ink-muted">
|
||||
offen · {v.jobTitle}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{!mitPersonen && unit.members.length > 0 && (
|
||||
<p className="text-[10px] text-ink-muted">
|
||||
{unit.members.length} {unit.members.length === 1 ? "Person" : "Personen"}
|
||||
|
||||
@@ -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<HTMLElement>("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<HTMLElement>("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<HTMLElement>("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 () => {
|
||||
|
||||
Reference in New Issue
Block a user