diff --git a/app/(app)/orgchart/print/page.tsx b/app/(app)/orgchart/print/page.tsx new file mode 100644 index 0000000..e8bed20 --- /dev/null +++ b/app/(app)/orgchart/print/page.tsx @@ -0,0 +1,43 @@ +import { PrintChart } from "@/components/orgchart/PrintChart"; +import type { OrgUnitNode } from "@/components/orgchart/types"; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; +import { todayIso } from "@/lib/format"; +import { loadOrgAsOf } from "@/lib/orgchart-data"; +import { buildPrintModel } from "@/lib/orgchart-print"; +import { parseIsoDateParam } from "@/lib/reports"; + +export const metadata = { title: "Organigramm drucken" }; + +type SearchParams = { asOf?: string }; + +// Eigene Route statt eines Druckstils auf der Organigramm-Seite. +// +// Die interaktive Ansicht ist eine Leinwand mit Zoom und Verschiebung; was +// davon auf Papier landet, hängt vom Zufallsstand der Ansicht ab. Hier wird +// stattdessen aus denselben Daten eine Seitenfolge gebaut, und der Stichtag +// wird aus der Adresse übernommen — wer im Organigramm einen Stichtag +// eingestellt hat, druckt genau den. +export default async function OrgChartPrintPage({ searchParams }: { searchParams: Promise }) { + const params = await searchParams; + const today = todayIso(); + const asOf = parseIsoDateParam(params.asOf) ?? today; + + const { org, units } = await withUser(await currentUserId(), async (tx) => { + const [org, units] = await Promise.all([ + loadOrgAsOf(tx, asOf), + tx + .selectFrom("org_units") + .select(["id", "org_number", "name", "parent_id", "unit_type"]) + .where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)])) + .where("valid_from", "<=", asOf) + .orderBy("org_number") + .execute(), + ]); + return { org, units }; + }); + + const model = buildPrintModel(units as OrgUnitNode[], org.employees, org.vacancies); + + return ; +} diff --git a/components/orgchart/OrgChartClient.tsx b/components/orgchart/OrgChartClient.tsx index f192ea3..8d0b75e 100644 --- a/components/orgchart/OrgChartClient.tsx +++ b/components/orgchart/OrgChartClient.tsx @@ -1,6 +1,8 @@ "use client"; import { useState } from "react"; +import { Printer } from "lucide-react"; +import Link from "next/link"; import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { AsOfPicker } from "./AsOfPicker"; import { EmployeeTree } from "./EmployeeTree"; @@ -35,14 +37,25 @@ export function OrgChartClient({ return (
- - value={view} - onChange={setView} - options={[ - { value: "ma", label: "Mitarbeiter" }, - { value: "pos", label: "Organisation" }, - ]} - /> +
+ + value={view} + onChange={setView} + options={[ + { value: "ma", label: "Mitarbeiter" }, + { value: "pos", label: "Organisation" }, + ]} + /> + {/* Der Stichtag wandert mit: wer eine vergangene Struktur ansieht, + druckt sie auch. */} + + + Als PDF + +
diff --git a/components/orgchart/PrintChart.tsx b/components/orgchart/PrintChart.tsx new file mode 100644 index 0000000..b60dc58 --- /dev/null +++ b/components/orgchart/PrintChart.tsx @@ -0,0 +1,406 @@ +"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 = { Gesellschaft: 0, Bereich: 1, Abteilung: 2, Team: 3 }; + +const MAX_EBENE: Record = { bereich: 1, abteilung: 2, team: 3, personen: 3 }; + +type Format = "a4" | "a3"; +const FORMATE: Record = { + 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("a4"); + const [tiefe, setTiefe] = useState("team"); + const [gewaehlt, setGewaehlt] = useState>(() => 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 ( +
+ + + {/* ── Auswahl ─────────────────────────────────────────── */} +
+
+
+

Was soll aufs Papier?

+

+ Das vollständige Organigramm sind {model.totals.people} Personen. Gedruckt wird selten alles — hier steht, + wie tief und welche Bereiche. +

+
+

+ Stand {fmtDate(asOf)} + {asOf !== today && " · abweichender Stichtag"} +

+
+ +
+

Tiefe

+
+ {TIEFEN.map((t) => ( + + ))} +
+
+ +
+
+

+ Bereiche ({gewaehlt.size} von {model.divisions.length}) +

+
+ + +
+
+
+ {model.divisions.map((d) => ( + + ))} +
+
+ +
+

+ {gewaehlt.size === 0 ? ( + Kein Bereich gewählt — es bliebe nur die Übersicht. + ) : ( + <> + Ergibt {seiten} {seiten === 1 ? "Seite" : "Seiten"} + + )} +

+
+ + Zurück + + +
+
+
+ + {schritt === "vorschau" && ( + <> +
+

+ {seiten} {seiten === 1 ? "Seite" : "Seiten"} · so kommt es aus dem Drucker +

+
+
+ {(Object.keys(FORMATE) as Format[]).map((f) => ( + + ))} +
+ +
+
+ +

+ Im Druckdialog „Als PDF speichern“ wählen. Hintergrundgrafiken müssen dort eingeschaltet sein, + sonst fehlen die farbigen Kopfzeilen — in Chrome unter „Weitere Einstellungen“. +

+ +
+ + + + + + {(tiefe === "team" || mitPersonen) && + bereiche.map((b, i) => ( + + + + + ))} +
+ + )} +
+ ); +} + +function Blatt({ children, breite }: { children: React.ReactNode; breite: string }) { + return ( +
+ {children} +
+ ); +} + +function Kopf({ titel, unterzeile, seite }: { titel: string; unterzeile: string; seite: string }) { + return ( +
+
+

{titel}

+

{unterzeile}

+
+

{seite}

+
+ ); +} + +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

Keine Organisationsstruktur vorhanden.

; + const kinder = bereiche ?? wurzel.children; + return ( +
    + +
+ ); +} + +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 ( +
  • + + {zeigeKinder && ( +
      + {naechste.map((k) => ( + + ))} +
    + )} +
  • + ); +} + +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 ( +
    +
    +

    {unit.name}

    +

    + {unit.unitType} · {unit.totalPeople} +

    +
    + +
    + {unit.chief ? ( + + ) : ( +

    Leitung unbesetzt

    + )} + + {mitPersonen && unit.members.length > 0 && ( +
    + {unit.members.map((p) => ( + + ))} +
    + )} + {mitPersonen && + unit.vacancies + .filter((v) => !v.isChief) + .map((v) => ( +

    + offen · {v.jobTitle} +

    + ))} + {!mitPersonen && unit.members.length > 0 && ( +

    + {unit.members.length} {unit.members.length === 1 ? "Person" : "Personen"} +

    + )} +
    +
    + ); +} + +function PersonZeile({ p, leitung }: { p: PrintPerson; leitung?: boolean }) { + return ( +

    + {p.name} + {p.absent && · abwesend} +
    + {p.jobTitle} +

    + ); +} diff --git a/lib/orgchart-print.ts b/lib/orgchart-print.ts new file mode 100644 index 0000000..0671886 --- /dev/null +++ b/lib/orgchart-print.ts @@ -0,0 +1,141 @@ +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(); + 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, + }, + }; +} diff --git a/tests/components/PrintChart.test.tsx b/tests/components/PrintChart.test.tsx new file mode 100644 index 0000000..e46f393 --- /dev/null +++ b/tests/components/PrintChart.test.tsx @@ -0,0 +1,157 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { PrintChart } from "@/components/orgchart/PrintChart"; +import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "@/components/orgchart/types"; +import { buildPrintModel } from "@/lib/orgchart-print"; + +// Der Druck ist die einzige Ansicht, deren Ergebnis niemand mehr korrigieren +// kann: was auf dem Papier fehlt, fällt erst im Besprechungsraum auf. Deshalb +// wird hier die Auswahl geprüft — die Tiefe und das Ab-/Anwählen von +// Bereichen entscheiden, was überhaupt gezeichnet wird. + +function unit(id: string, name: string, type: OrgUnitNode["unit_type"], parent: string | null, nr: string): OrgUnitNode { + return { id, org_number: nr, name, parent_id: parent, unit_type: type }; +} + +const UNITS: OrgUnitNode[] = [ + unit("g", "Alpenwerk GmbH", "Gesellschaft", null, "1000"), + unit("b1", "Produktion", "Bereich", "g", "1100"), + unit("a1", "Fertigung", "Abteilung", "b1", "1110"), + unit("t1", "Schicht A", "Team", "a1", "1111"), + unit("b2", "Vertrieb", "Bereich", "g", "1200"), + unit("a2", "Innendienst", "Abteilung", "b2", "1210"), +]; + +function person(id: string, last: string, unitId: string, chief = false): OrgEmployee { + return { + id, + personnel_number: Number(id), + first_name: "Test", + last_name: last, + job_title: chief ? "Leitung" : "Sachbearbeitung", + manager_id: null, + formal_manager_id: null, + absent: false, + absence_type: null, + org_unit_id: unitId, + is_chief: chief, + position_id: `p${id}`, + position_number: `S${id}`, + }; +} + +const EMPLOYEES: OrgEmployee[] = [ + person("1", "Wiesinger", "g", true), + person("2", "Steinbacher", "b1", true), + person("3", "Aigner", "t1"), + person("4", "Berger", "b2", true), + person("5", "Cerny", "a2"), +]; + +const VACANCIES: OrgVacancy[] = [ + { position_id: "p9", position_number: "S9", job_title: "Disposition", org_unit_id: "a1", is_chief: false }, +]; + +function renderChart() { + const model = buildPrintModel(UNITS, EMPLOYEES, VACANCIES); + return render(); +} + +/** Die Blätter der Vorschau — je Blatt eine Druckseite. */ +function blaetter(): HTMLElement[] { + return Array.from(document.querySelectorAll(".blatt")); +} + +async function zurVorschau(user: ReturnType) { + await user.click(screen.getByRole("button", { name: /Vorschau/ })); +} + +describe("PrintChart", () => { + it("fragt zuerst, was gedruckt wird, und zeigt vorher kein Blatt", () => { + renderChart(); + expect(screen.getByText("Was soll aufs Papier?")).toBeInTheDocument(); + expect(blaetter()).toHaveLength(0); + }); + + it("wählt alle Bereiche vor und druckt je Bereich eine Seite plus Übersicht", async () => { + const user = userEvent.setup(); + renderChart(); + expect(screen.getByRole("checkbox", { name: /Produktion/ })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: /Vertrieb/ })).toBeChecked(); + + await zurVorschau(user); + expect(blaetter()).toHaveLength(3); + }); + + it("lässt einen abgewählten Bereich ganz weg — auch aus der Übersicht", async () => { + const user = userEvent.setup(); + renderChart(); + await user.click(screen.getByRole("checkbox", { name: /Vertrieb/ })); + await zurVorschau(user); + + const seiten = blaetter(); + expect(seiten).toHaveLength(2); + expect(within(seiten[0]).queryByText("Vertrieb")).not.toBeInTheDocument(); + expect(within(seiten[0]).getByText("Produktion")).toBeInTheDocument(); + }); + + it("„Keine“ lässt nur die Übersicht übrig", async () => { + const user = userEvent.setup(); + renderChart(); + await user.click(screen.getByRole("button", { name: "Keine" })); + await zurVorschau(user); + expect(blaetter()).toHaveLength(1); + }); + + it("bricht bei „Bereiche“ nach der ersten Ebene ab", async () => { + const user = userEvent.setup(); + renderChart(); + await user.click(screen.getByRole("button", { name: /Bereiche/ })); + await zurVorschau(user); + + const seiten = blaetter(); + // Nur die Übersicht, und darin keine Abteilung. + expect(seiten).toHaveLength(1); + expect(within(seiten[0]).getByText("Produktion")).toBeInTheDocument(); + expect(within(seiten[0]).queryByText("Fertigung")).not.toBeInTheDocument(); + }); + + it("zeichnet hierarchisch: die Abteilung hängt unter ihrem Bereich", async () => { + const user = userEvent.setup(); + renderChart(); + await zurVorschau(user); + + // Der Bereichsname steht auf der Seite zweimal — in der Kopfzeile und im + // Wurzelkasten des Baums. Geprüft wird der Baum. + const baum = blaetter()[1].querySelector("ul.baum")!; + const fertigung = within(baum).getByText("Fertigung"); + const bereichsKnoten = within(baum).getByText("Produktion").closest("li")!; + expect(bereichsKnoten.contains(fertigung)).toBe(true); + // Und das Team wiederum unter der Abteilung. + expect(fertigung.closest("li")!.contains(within(baum).getByText("Schicht A"))).toBe(true); + }); + + it("nennt Namen erst bei „mit Personen“", async () => { + const user = userEvent.setup(); + renderChart(); + await zurVorschau(user); + // Führungskräfte stehen immer im Kasten, gewöhnliche Mitarbeitende nicht. + expect(screen.getAllByText("Test Steinbacher").length).toBeGreaterThan(0); + expect(screen.queryByText("Test Aigner")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /mit Personen/ })); + expect(screen.getByText("Test Aigner")).toBeInTheDocument(); + expect(screen.getByText(/offen · Disposition/)).toBeInTheDocument(); + }); + + it("meldet unbesetzte Leitungen statt sie zu verschweigen", async () => { + const user = userEvent.setup(); + renderChart(); + await zurVorschau(user); + // Auf der Produktionsseite: Fertigung und Schicht A stehen ohne Leitung da. + const seite = blaetter()[1]; + expect(within(seite).getAllByText("Leitung unbesetzt")).toHaveLength(2); + expect(within(seite).getByText("Test Steinbacher")).toBeInTheDocument(); + }); +});