The chart on screen is an infinite canvas: you zoom in, drag around, and look at one corner at a time. Paper has none of that. Printing the canvas means scaling 850 people onto one sheet, which yields boxes two millimetres wide — technically the whole company, practically nothing. So the print view is rebuilt rather than shrunk, and it does two things the canvas cannot. It asks before it prints. Depth (bereiche, abteilungen, teams, or teams with every name) and which divisions, each one selectable. Whoever needs Produktion for a meeting gets two sheets instead of forty, and the page count is on the button before anything reaches the printer. And it draws the hierarchy as a hierarchy: boxes joined by connecting lines, not a column of cards. Superior and subordinate are the entire point of an org chart; a tidy list of the same units simply does not say it. The lines come from borders on pseudo-elements, so the PDF keeps them as vectors and they stay sharp when someone zooms in. Header shading gets weaker with each level down, which survives the black-and- white printer that most of these end up on. Overview sheet first, then one sheet per selected division, each carrying its own heading and headcount so page seven is still readable on its own. A4 or A3, landscape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
407 lines
16 KiB
TypeScript
407 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { ChevronRight, Printer } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useMemo, 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:
|
|
//
|
|
// **Erstens wird gefragt, bevor gedruckt wird.** Ein Organigramm mit 850
|
|
// 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.
|
|
|
|
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" },
|
|
];
|
|
|
|
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" },
|
|
};
|
|
|
|
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 [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 mitPersonen = tiefe === "personen";
|
|
const seiten = 1 + (mitPersonen || tiefe === "team" ? bereiche.length : 0);
|
|
|
|
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 (
|
|
<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; margin: 0 !important;
|
|
width: auto !important; max-width: none !important; padding: 0 !important;
|
|
break-after: page;
|
|
}
|
|
.blatt:last-child { break-after: auto; }
|
|
.nicht-trennen { break-inside: avoid; }
|
|
}
|
|
/* 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 {
|
|
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);
|
|
}
|
|
`}</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>
|
|
</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}</strong> {seiten === 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} {seiten === 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>
|
|
|
|
<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}>
|
|
<Kopf
|
|
titel={model.root?.name ?? "Organigramm"}
|
|
unterzeile={`Organigramm · Stand ${fmtDate(asOf)} · ${model.totals.people} Personen`}
|
|
seite="Übersicht"
|
|
/>
|
|
<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} />
|
|
</Blatt>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Blatt({ children, breite }: { children: React.ReactNode; breite: string }) {
|
|
return (
|
|
<div
|
|
className="blatt w-full overflow-x-auto rounded border border-border bg-white p-8 shadow-[var(--shadow-card)]"
|
|
style={{ maxWidth: breite }}
|
|
>
|
|
{children}
|
|
</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">
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function Baum({
|
|
wurzel,
|
|
bereiche,
|
|
maxEbene,
|
|
mitPersonen,
|
|
}: {
|
|
wurzel: PrintUnit | null;
|
|
/** Auf der Übersicht nur die gewählten Bereiche unter die Wurzel hängen. */
|
|
bereiche: PrintUnit[] | null;
|
|
maxEbene: number;
|
|
mitPersonen: boolean;
|
|
}) {
|
|
if (!wurzel) return <p className="text-sm text-ink-muted">Keine Organisationsstruktur vorhanden.</p>;
|
|
const kinder = bereiche ?? wurzel.children;
|
|
return (
|
|
<ul className="baum">
|
|
<Knoten unit={wurzel} kinder={kinder} maxEbene={maxEbene} mitPersonen={mitPersonen} />
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<li>
|
|
<Kasten unit={unit} mitPersonen={mitPersonen} />
|
|
{zeigeKinder && (
|
|
<ul>
|
|
{naechste.map((k) => (
|
|
<Knoten key={k.id} unit={k} maxEbene={maxEbene} mitPersonen={mitPersonen} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
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";
|
|
|
|
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}`}>
|
|
<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"}`}>
|
|
{unit.unitType} · {unit.totalPeople}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="px-2 py-1">
|
|
{unit.chief ? (
|
|
<PersonZeile p={unit.chief} leitung />
|
|
) : (
|
|
<p className="text-[10px] italic text-ink-muted">Leitung unbesetzt</p>
|
|
)}
|
|
|
|
{mitPersonen && unit.members.length > 0 && (
|
|
<div className="mt-1 border-t border-border-subtle pt-1">
|
|
{unit.members.map((p) => (
|
|
<PersonZeile key={p.id} p={p} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{mitPersonen &&
|
|
unit.vacancies
|
|
.filter((v) => !v.isChief)
|
|
.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 }) {
|
|
return (
|
|
<p className="text-[10px] leading-tight">
|
|
<span className={leitung ? "font-bold text-ink" : "text-ink"}>{p.name}</span>
|
|
{p.absent && <span className="text-ink-muted"> · abwesend</span>}
|
|
<br />
|
|
<span className="text-ink-muted">{p.jobTitle}</span>
|
|
</p>
|
|
);
|
|
}
|