Files
alpenwerk-hr/lib/orgchart-print.ts
Maximilian Stubhan 8a76b3688f Show people surname first
Employee names now read "Winkler, Hannah" wherever a person appears in a
list, a table, a heading or a tree node. That is the order a personnel
list is kept in, it is the order people are looked up in, and it finally
matches the sorting — the employee list has always been ordered by
surname, which made an alphabetical page look unsorted.

The name was being assembled inline in about twenty places. A rename
that catches half of them is worse than none, so it now goes through
fmtName in lib/format.ts and every display site calls it.

Sentences keep the natural order: "Hannah Winkler wurde versetzt" reads
like German, "Winkler, Hannah wurde versetzt" reads like a form. So the
toasts are unchanged and only labels moved.

Two things the change would have quietly broken:

The org chart's own filter matched against "first last". It now matches
either order, with or without the comma, so typing what you see works
and so does typing what you remember.

The print model sorted by the last word of the composed name, which
happened to be the surname and is now the first name — every printed
unit would have come out sorted by first name. It sorts on the surname
field itself now, which is what it meant all along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:44:03 +02:00

153 lines
5.2 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 };
};
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. 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,
},
};
}