Files
alpenwerk-hr/lib/orgchart-print.ts
Andrei Laas 1baf8c2867
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 12m9s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m41s
Im Druck jeden untersten Bereich als Blatt, und die Zwischenebene zeichnen
Die Blaetter kamen aus `root.children`, also aus den unmittelbaren Kindern
der Gesellschaft. Das stimmte, solange die Bereiche dort hingen. Beim
Kunden liegt "CEO" dazwischen und ist selbst ein Bereich: die Auswahl bot
einen einzigen Eintrag an, und aus 784 Personen wurde die Uebersicht plus
ein Blatt fuer CEO.

Genommen wird jetzt jeder Bereich, unter dem kein weiterer liegt -- nach
dem Etikett und nicht nach der Stellung im Baum. Nur nach dem Etikett zu
gehen genuegte nicht: CEO kaeme als eigenes Blatt dazu und enthielte die
sechs anderen noch einmal.

Die Uebersicht haengte die gewaehlten Bereiche unmittelbar unter die
Gesellschaft und haette CEO damit stillschweigend uebersprungen. Sie
zeichnet jetzt den echten Weg von oben, beschnitten auf die Aeste mit
einem gewaehlten Bereich, und geht so tief, wie dieser Baum reicht. Das
Ab- und Anwaehlen wirkt weiterhin bis in die Uebersicht.

Bei der Gesellschaft entfaellt "Leitung unbesetzt": sie wird nicht
gefuehrt, sondern ist das Ganze. Ueberall sonst bleibt der Vermerk.
2026-09-23 10:19:53 +02:00

176 lines
6.3 KiB
TypeScript

import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "@/components/orgchart/types";
import { fmtName } from "@/lib/format";
// 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;
/** Getrennt mitgeführt, weil danach sortiert wird — siehe nachName. */
lastName: 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 };
};
/** Liegt unterhalb dieser Einheit noch ein Bereich? */
function enthaeltBereich(unit: PrintUnit): boolean {
return unit.children.some((k) => k.unitType === "Bereich" || enthaeltBereich(k));
}
function personFrom(e: OrgEmployee): PrintPerson {
return {
id: e.id,
lastName: e.last_name,
name: fmtName(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.
*
* Der Nachname kommt aus dem eigenen Feld, nicht aus dem zusammengesetzten
* Namen. Vorher wurde das letzte Wort genommen, was bei „Hannah Winkler"
* zufällig stimmte; seit die Anzeige „Winkler, Hannah" lautet, wäre das
* letzte Wort der Vorname, und die Liste stünde nach Vornamen sortiert da.
*/
function nachName(a: PrintPerson, b: PrintPerson): number {
return a.lastName.localeCompare(b.lastName, "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, über beliebig viele Ebenen. `unit_type` ist
// ein Etikett und begrenzt die Tiefe nicht — hier stand einmal das
// Gegenteil.
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);
// Ein Blatt je Bereich — und zwar je Bereich, unter dem kein weiterer mehr
// liegt.
//
// Hier stand `root.children`, also „die unmittelbaren Kinder der
// Gesellschaft". Das stimmte, solange die Bereiche unmittelbar darunter
// hingen. Bei Manner liegt „CEO" dazwischen und ist selbst ein Bereich: die
// Auswahl bot genau einen Eintrag an, und der Ausdruck bestand aus der
// Übersicht plus einem einzigen Blatt für CEO.
//
// Nach dem Etikett zu gehen allein genügt nicht — dann käme CEO als siebtes
// Blatt dazu und enthielte die sechs anderen noch einmal. Deshalb bleiben
// nur die untersten Bereiche übrig. Im alten Modell ändert das nichts: dort
// liegt unter einem Bereich nie ein zweiter.
const bereiche = [...byUnit.values()]
.filter((u) => u.unitType === "Bereich" && !enthaeltBereich(u))
.sort((a, b) => a.orgNumber.localeCompare(b.orgNumber, "de"));
// Ohne jedes Etikett „Bereich" bliebe sonst nichts zum Drucken übrig.
const divisions = bereiche.length > 0 ? bereiche : root ? root.children : [];
return {
root,
divisions,
totals: {
people: employees.length,
vacancies: vacancies.length,
units: units.length,
},
};
}