diff --git a/components/orgchart/PrintChart.tsx b/components/orgchart/PrintChart.tsx index d00e4ab..9f70c0b 100644 --- a/components/orgchart/PrintChart.tsx +++ b/components/orgchart/PrintChart.tsx @@ -58,27 +58,58 @@ type Seite = { titel: string; unterzeile: string; wurzel: PrintUnit; - /** Nur auf der Übersicht: die gewählten Bereiche statt aller Kinder. */ - kinder?: 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, 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: model.root, - kinder: bereiche, - // Bei „bis Abteilung" ist die Übersicht das ganze Ergebnis. Sonst folgen - // Detailblätter, und dann bleibt sie eine reine Bereichsübersicht. - ebenen: tiefe === "abteilung" ? 2 : 1, + 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, }); } @@ -496,7 +527,7 @@ function Skaliert({ } function Chart({ seite }: { seite: Seite }) { - const kinder = seite.kinder ?? seite.wurzel.children; + const kinder = seite.wurzel.children; const zeigeKinder = seite.ebenen > 0 && kinder.length > 0; return ( @@ -566,11 +597,15 @@ function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }
+ {/* 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 ? ( - ) : ( + ) : unit.unitType !== "Gesellschaft" ? (

Leitung unbesetzt

- )} + ) : null} {mitPersonen && unit.members.length > 0 && (
diff --git a/lib/orgchart-print.ts b/lib/orgchart-print.ts index a89d601..487a98f 100644 --- a/lib/orgchart-print.ts +++ b/lib/orgchart-print.ts @@ -52,6 +52,11 @@ export type PrintModel = { 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, @@ -128,8 +133,9 @@ export function buildPrintModel( 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). + // 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); @@ -138,7 +144,24 @@ export function buildPrintModel( 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"); + // 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, diff --git a/tests/components/PrintChart.test.tsx b/tests/components/PrintChart.test.tsx index 852266f..ea5f143 100644 --- a/tests/components/PrintChart.test.tsx +++ b/tests/components/PrintChart.test.tsx @@ -208,3 +208,73 @@ describe("PrintChart", () => { expect(within(seite).getByText("Steinbacher, Test")).toBeInTheDocument(); }); }); + +// Beim Kunden liegt zwischen der Gesellschaft und den fachlichen Bereichen +// noch „CEO" — selbst ein Bereich. Daran ist der Druck gescheitert: die +// Auswahl bot einen einzigen Eintrag an, und aus dem ganzen Haus wurde ein +// Blatt für CEO. +describe("PrintChart — ein Bereich über den Bereichen", () => { + const UNITS_TIEF: OrgUnitNode[] = [ + unit("ag", "Manner AG", "Gesellschaft", null, "0000"), + unit("ceo", "CEO", "Bereich", "ag", "0080"), + unit("cfo", "CFO", "Bereich", "ceo", "0001"), + unit("coo", "COO", "Bereich", "ceo", "0083"), + unit("w17", "Standortleitung W17", "Abteilung", "coo", "0088"), + unit("schnitte", "Leitung Schnitte", "Team", "w17", "0101"), + ]; + const PERSONEN_TIEF: OrgEmployee[] = [ + // Die Gesellschaft bleibt bewusst ohne Leitung — so ist es beim Kunden. + person("11", "Obermayr", "ceo", true), + person("12", "Hofer", "cfo", true), + person("13", "Gruber", "coo", true), + person("14", "Nagl", "schnitte"), + ]; + + function renderTief() { + return render( + + ); + } + + it("bietet die untersten Bereiche an, nicht den darüber", () => { + renderTief(); + expect(screen.getByRole("checkbox", { name: /CFO/ })).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: /COO/ })).toBeInTheDocument(); + // CEO enthält die beiden anderen; als eigenes Blatt stünde das ganze Haus + // ein zweites Mal darin. + expect(screen.queryByRole("checkbox", { name: /^CEO/ })).not.toBeInTheDocument(); + expect(blaetter()).toHaveLength(0); + }); + + it("zeichnet die Zwischenebene mit, statt sie zu überspringen", async () => { + const user = userEvent.setup(); + renderTief(); + await zurVorschau(user); + + const uebersicht = blaetter()[0]; + // Zweimal: als Überschrift des Blatts und als oberster Kasten. + expect(within(uebersicht).getAllByText("Manner AG")).toHaveLength(2); + // Ohne diese Zeile hinge CFO unmittelbar unter der AG — eine Ebene, die + // es gibt, wäre aus dem Organigramm verschwunden. + expect(within(uebersicht).getByText("CEO")).toBeInTheDocument(); + expect(within(uebersicht).getByText("CFO")).toBeInTheDocument(); + expect(within(uebersicht).getByText("COO")).toBeInTheDocument(); + }); + + it("gibt jedem untersten Bereich ein Blatt", async () => { + const user = userEvent.setup(); + renderTief(); + await zurVorschau(user); + // Übersicht plus CFO plus COO. + expect(blaetter()).toHaveLength(3); + }); + + it("vermerkt bei der Gesellschaft keine unbesetzte Leitung", async () => { + const user = userEvent.setup(); + renderTief(); + await zurVorschau(user); + // Die oberste Einheit wird nicht geführt, sondern ist das Ganze. Alle + // übrigen Kästen der Übersicht haben eine Leitung. + expect(within(blaetter()[0]).queryByText("Leitung unbesetzt")).not.toBeInTheDocument(); + }); +});