Von Max gewuenscht, ausdruecklich mit Auswahl oben. Ein Haekchen in "Was soll aufs Papier?" stellt die Nummer hinter den Namen -- nicht in eine eigene Zeile: auf einem Blatt mit zweihundert Kaesten kostet jede zusaetzliche Zeile Massstab, und der entscheidet darueber, ob das Blatt noch lesbar ist. Angeboten auf jeder Tiefe, nicht nur bei "mit Personen". Die Leitung einer Einheit steht auch auf den Blaettern, die sonst keine Namen fuehren; ein Haekchen, das dort fehlte, saehe aus, als gaebe es dort keine Namen. Als Kontext statt als Eigenschaft durch den Baum: die Angabe betrifft eine einzige Zeile ganz unten, muesste aber sonst durch vier Signaturen der Seitenerzeugung durchgereicht werden. Und die Kennung der Messung traegt den Schalter mit -- die Zeilen werden breiter, also muss der Massstab neu gemessen werden, sonst liefe das Blatt ueber den Rand.
682 lines
28 KiB
TypeScript
682 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import { ChevronRight, Printer } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { createContext, useCallback, useContext, 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" },
|
|
];
|
|
|
|
/**
|
|
* Ob die Personalnummer mitgedruckt wird.
|
|
*
|
|
* Als Kontext und nicht als Eigenschaft durch den Baum: die Angabe betrifft
|
|
* eine einzige Zeile ganz unten (PersonZeile), müsste aber sonst durch jede
|
|
* Ebene der Seitenerzeugung durchgereicht werden — vier Signaturen für ein
|
|
* Häkchen. Gewünscht von Max am 30.09., ausdrücklich mit Wahlmöglichkeit.
|
|
*/
|
|
const NummerImDruck = createContext(false);
|
|
|
|
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<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;
|
|
/** 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<string>, 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<Format>("a4");
|
|
const [tiefe, setTiefe] = useState<Tiefe>("team");
|
|
const [mitNummer, setMitNummer] = useState(false);
|
|
const [gewaehlt, setGewaehlt] = useState<Set<string>>(() => 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<string, number> }>({ 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 (
|
|
<NummerImDruck.Provider value={mitNummer}>
|
|
<div className="flex flex-col gap-6">
|
|
<style>{`
|
|
@page { size: ${FORMATE[format].css}; margin: 12mm 14mm 14mm; }
|
|
@media print {
|
|
html, body { background: #fff; }
|
|
.nur-bildschirm { display: none !important; }
|
|
.blatt {
|
|
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; }
|
|
/* 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; }
|
|
}
|
|
|
|
/* 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);
|
|
}
|
|
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>
|
|
|
|
{/* ── Auswahl ─────────────────────────────────────────── */}
|
|
<section className="nur-bildschirm rounded border border-border bg-white p-5">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-sm font-bold text-ink">Was soll aufs Papier?</h2>
|
|
<p className="mt-0.5 max-w-prose text-sm text-ink-muted">
|
|
Das vollständige Organigramm sind {model.totals.people} Personen. Gedruckt wird selten alles — hier steht,
|
|
wie tief und welche Bereiche.
|
|
</p>
|
|
</div>
|
|
<p className="whitespace-nowrap text-sm text-ink-muted">
|
|
Stand {fmtDate(asOf)}
|
|
{asOf !== today && " · abweichender Stichtag"}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-5">
|
|
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-ink-muted">Tiefe</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{TIEFEN.map((t) => (
|
|
<button
|
|
key={t.wert}
|
|
type="button"
|
|
onClick={() => setTiefe(t.wert)}
|
|
aria-pressed={tiefe === t.wert}
|
|
className={`rounded-md border px-3 py-2 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
|
tiefe === t.wert ? "border-brand-500 bg-brand-50" : "border-border hover:bg-surface"
|
|
}`}
|
|
>
|
|
<span className="block text-sm font-semibold text-ink">{t.label}</span>
|
|
<span className="block text-xs text-ink-muted">{t.hinweis}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Auf jeder Tiefe angeboten, nicht nur bei „mit Personen": die
|
|
Leitung einer Einheit steht auch auf den Blättern, die sonst
|
|
keine Namen führen. Ein Häkchen, das dort fehlte, sähe aus, als
|
|
gäbe es dort keine Namen. */}
|
|
<label className="mt-3 flex items-center gap-2 text-sm text-ink-body">
|
|
<input type="checkbox" checked={mitNummer} onChange={(e) => setMitNummer(e.target.checked)} />
|
|
Personalnummer hinter den Namen drucken
|
|
</label>
|
|
</div>
|
|
|
|
<div className="mt-5">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<p className="text-xs font-bold uppercase tracking-wide text-ink-muted">
|
|
Bereiche ({gewaehlt.size} von {model.divisions.length})
|
|
</p>
|
|
<div className="flex gap-3 text-xs">
|
|
<button
|
|
type="button"
|
|
onClick={() => setGewaehlt(new Set(model.divisions.map((d) => d.id)))}
|
|
className="rounded font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
|
>
|
|
Alle
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setGewaehlt(new Set())}
|
|
className="rounded font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
|
>
|
|
Keine
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
{model.divisions.map((d) => (
|
|
<label
|
|
key={d.id}
|
|
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 ${
|
|
gewaehlt.has(d.id) ? "border-brand-500 bg-brand-50" : "border-border"
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={gewaehlt.has(d.id)}
|
|
onChange={() => umschalten(d.id)}
|
|
className="mt-0.5 h-4 w-4 rounded border-border text-brand-600"
|
|
/>
|
|
<span>
|
|
<span className="block text-sm font-semibold text-ink">{d.name}</span>
|
|
<span className="block text-xs text-ink-muted">
|
|
{d.totalPeople} Personen · {d.children.length} Abteilungen
|
|
</span>
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-border-subtle pt-4">
|
|
<p className="text-sm text-ink-muted">
|
|
{gewaehlt.size === 0 ? (
|
|
<span className="text-danger-text">Kein Bereich gewählt — es bliebe nur die Übersicht.</span>
|
|
) : (
|
|
<>
|
|
Ergibt <strong className="text-ink">{seiten.length}</strong> {seiten.length === 1 ? "Seite" : "Seiten"}
|
|
</>
|
|
)}
|
|
</p>
|
|
<div className="flex items-center gap-3">
|
|
<Link
|
|
href="/orgchart"
|
|
className="rounded-md border border-border px-3 py-2 text-sm font-semibold text-ink hover:bg-brand-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
|
>
|
|
Zurück
|
|
</Link>
|
|
<Button onClick={() => setSchritt("vorschau")}>
|
|
Vorschau <ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{schritt === "vorschau" && (
|
|
<>
|
|
<div className="nur-bildschirm flex flex-wrap items-center justify-between gap-3">
|
|
<p className="text-sm text-ink-muted">
|
|
{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">
|
|
{(Object.keys(FORMATE) as Format[]).map((f) => (
|
|
<button
|
|
key={f}
|
|
type="button"
|
|
onClick={() => setFormat(f)}
|
|
aria-pressed={format === f}
|
|
className={`rounded px-3 py-1.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
|
format === f ? "bg-brand-500 text-white" : "text-ink-muted hover:bg-surface"
|
|
}`}
|
|
>
|
|
{FORMATE[f].label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<Button onClick={() => window.print()}>
|
|
<Printer className="h-4 w-4" />
|
|
Als PDF speichern
|
|
</Button>
|
|
</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">
|
|
{seiten.map((seite, i) => (
|
|
<Blatt key={seite.key} format={format}>
|
|
<Kopf
|
|
titel={seite.titel}
|
|
unterzeile={seite.unterzeile}
|
|
seite={i === 0 ? "Übersicht" : `Seite ${i + 1} von ${seiten.length}`}
|
|
/>
|
|
{/* Die Kennung trägt `mitNummer` mit: die Zeilen werden
|
|
breiter, also muss der Massstab neu gemessen werden. Ohne
|
|
das bliebe der Wert des vorigen Laufs stehen, und das Blatt
|
|
liefe über den Rand. */}
|
|
<Skaliert
|
|
kennung={`${seite.key}|${format}|${tiefe}|${mitNummer}`}
|
|
onFaktor={(f) => meldeMassstab(seite.key, f)}
|
|
>
|
|
<Chart seite={seite} />
|
|
</Skaliert>
|
|
</Blatt>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</NummerImDruck.Provider>
|
|
);
|
|
}
|
|
|
|
function Blatt({ children, format }: { children: React.ReactNode; format: Format }) {
|
|
const { blatt, satzBreite, satzHoehe } = FORMATE[format];
|
|
return (
|
|
<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-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>
|
|
</div>
|
|
<p className="whitespace-nowrap text-xs font-semibold text-ink-muted">{seite}</p>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<HTMLDivElement>(null);
|
|
const inhalt = useRef<HTMLDivElement>(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 (
|
|
<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 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.
|
|
<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 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>
|
|
)}
|
|
</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.
|
|
// 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 (
|
|
<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 ${
|
|
unit.unitType === "Gesellschaft" ? "text-white/75" : "text-ink-muted"
|
|
}`}
|
|
>
|
|
{unit.unitType} · {unit.totalPeople}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="px-2 py-1">
|
|
{/* 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 ? (
|
|
<PersonZeile p={unit.chief} leitung />
|
|
) : unit.unitType !== "Gesellschaft" ? (
|
|
<p className="text-[10px] italic text-ink-muted">Leitung unbesetzt</p>
|
|
) : null}
|
|
|
|
{mitPersonen && unit.members.length > 0 && (
|
|
<div className={`mt-1 border-t border-border-subtle pt-1 ${zweispaltig ? "columns-2 gap-2" : ""}`}>
|
|
{unit.members.map((p) => (
|
|
<div key={p.id} className="break-inside-avoid">
|
|
<PersonZeile p={p} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{mitPersonen &&
|
|
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"}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PersonZeile({ p, leitung }: { p: PrintPerson; leitung?: boolean }) {
|
|
const mitNummer = useContext(NummerImDruck);
|
|
return (
|
|
<p className="text-[10px] leading-tight">
|
|
<span className={leitung ? "font-bold text-ink" : "text-ink"}>{p.name}</span>
|
|
{/* Die Personalnummer hinter den Namen und nicht in eine eigene Zeile:
|
|
auf einem Blatt mit zweihundert Kästen kostet jede zusätzliche Zeile
|
|
Massstab, und der entscheidet darüber, ob das Blatt noch lesbar ist.
|
|
Tabellenziffern, damit die Nummern untereinander stehen. */}
|
|
{mitNummer && <span className="text-ink-muted tabular-nums"> · {p.personnelNumber}</span>}
|
|
{p.absent && <span className="text-ink-muted"> · abwesend</span>}
|
|
<br />
|
|
<span className="text-ink-muted">{p.jobTitle}</span>
|
|
</p>
|
|
);
|
|
}
|