Files
alpenwerk-hr/lib/orgchart-print.ts
Maximilian Stubhan 3151f32404 Print the org chart as an org chart, and ask first what to print
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>
2026-08-11 22:03:54 +02:00

142 lines
4.7 KiB
TypeScript

import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "@/components/orgchart/types";
// Das Modell für den Druck.
//
// Der Bildschirm zeigt das Organigramm als Fläche, in die man hineinzoomt.
// Papier kann das nicht: es hat eine feste Grösse, und was darauf nicht
// lesbar ist, ist verloren. Ein Ausdruck der Leinwand — 850 Personen auf eine
// Seite skaliert — ergibt Kästchen von zwei Millimetern.
//
// Deshalb wird für den Druck **umgebaut statt verkleinert**: eine Übersicht
// über die obersten Ebenen, und danach ein Bereich je Seite als eigener,
// vollständiger Baum. Jede Seite ist für sich verständlich und trägt ihre
// Überschrift — auch wenn jemand nur Blatt sieben aus dem Drucker nimmt.
//
// Der Baum bleibt hier ein Baum: `children` und `totalPeople` sind das, woraus
// die Auswahl („welche Bereiche, wie tief") und die gezeichnete Hierarchie
// gleichermassen entstehen.
export type PrintPerson = {
id: string;
name: string;
jobTitle: string;
personnelNumber: number;
isChief: boolean;
absent: boolean;
absenceType: string | null;
};
export type PrintUnit = {
id: string;
name: string;
unitType: OrgUnitNode["unit_type"];
orgNumber: string;
/** Die Leitung dieser Einheit, falls besetzt. */
chief: PrintPerson | null;
/** Alle übrigen Personen, die unmittelbar in dieser Einheit sitzen. */
members: PrintPerson[];
/** Unbesetzte Planstellen dieser Einheit. */
vacancies: { positionNumber: string; jobTitle: string; isChief: boolean }[];
children: PrintUnit[];
/** Personen in dieser Einheit und allem darunter — für die Übersicht. */
totalPeople: number;
};
export type PrintModel = {
root: PrintUnit | null;
/** Je Bereich eine Seite. Bei nur einer Ebene bleibt es bei der Übersicht. */
divisions: PrintUnit[];
totals: { people: number; vacancies: number; units: number };
};
function personFrom(e: OrgEmployee): PrintPerson {
return {
id: e.id,
name: `${e.first_name} ${e.last_name}`,
jobTitle: e.job_title,
personnelNumber: e.personnel_number,
isChief: e.is_chief,
absent: e.absent,
absenceType: e.absence_type,
};
}
/** Nachname, dann Vorname — wie in jeder Liste, die jemand durchsucht. */
function nachName(a: PrintPerson, b: PrintPerson): number {
return a.name.split(" ").slice(-1)[0].localeCompare(b.name.split(" ").slice(-1)[0], "de") || a.name.localeCompare(b.name, "de");
}
export function buildPrintModel(
units: OrgUnitNode[],
employees: OrgEmployee[],
vacancies: OrgVacancy[]
): PrintModel {
const byUnit = new Map<string, PrintUnit>();
for (const u of units) {
byUnit.set(u.id, {
id: u.id,
name: u.name,
unitType: u.unit_type,
orgNumber: u.org_number,
chief: null,
members: [],
vacancies: [],
children: [],
totalPeople: 0,
});
}
for (const e of employees) {
const unit = byUnit.get(e.org_unit_id);
if (!unit) continue;
const p = personFrom(e);
// Die Leitung steht oben und nicht in der Namensliste — sie ist die
// Antwort auf „wer führt das hier", und die soll man nicht suchen.
if (p.isChief && !unit.chief) unit.chief = p;
else unit.members.push(p);
}
for (const v of vacancies) {
const unit = byUnit.get(v.org_unit_id);
if (!unit) continue;
unit.vacancies.push({ positionNumber: v.position_number, jobTitle: v.job_title, isChief: v.is_chief });
}
// Bäume verknüpfen, in der Reihenfolge der Orgnummern — dieselbe wie
// überall sonst in der Anwendung.
const sortiert = [...units].sort((a, b) => a.org_number.localeCompare(b.org_number, "de"));
let root: PrintUnit | null = null;
for (const u of sortiert) {
const node = byUnit.get(u.id)!;
if (u.parent_id && byUnit.has(u.parent_id)) byUnit.get(u.parent_id)!.children.push(node);
else if (u.unit_type === "Gesellschaft") root = node;
}
for (const unit of byUnit.values()) {
unit.members.sort(nachName);
unit.vacancies.sort((a, b) => a.jobTitle.localeCompare(b.jobTitle, "de"));
}
// Summen von unten nach oben. Rekursiv über höchstens vier Ebenen — die
// Tiefe ist durch das Modell begrenzt (Gesellschaft/Bereich/Abteilung/Team).
function zaehle(unit: PrintUnit): number {
const eigene = (unit.chief ? 1 : 0) + unit.members.length;
unit.totalPeople = eigene + unit.children.reduce((s, c) => s + zaehle(c), 0);
return unit.totalPeople;
}
if (root) zaehle(root);
else for (const u of byUnit.values()) if (!units.find((x) => x.id === u.id)?.parent_id) zaehle(u);
const divisions = root ? root.children : [...byUnit.values()].filter((u) => u.unitType === "Bereich");
return {
root,
divisions,
totals: {
people: employees.length,
vacancies: vacancies.length,
units: units.length,
},
};
}