From 3151f3240402dab1551e48e13e4af4f1cecd26fd Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Tue, 11 Aug 2026 22:03:54 +0200 Subject: [PATCH] Print the org chart as an org chart, and ask first what to print MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/(app)/orgchart/print/page.tsx | 43 +++ components/orgchart/OrgChartClient.tsx | 29 +- components/orgchart/PrintChart.tsx | 406 +++++++++++++++++++++++++ lib/orgchart-print.ts | 141 +++++++++ tests/components/PrintChart.test.tsx | 157 ++++++++++ 5 files changed, 768 insertions(+), 8 deletions(-) create mode 100644 app/(app)/orgchart/print/page.tsx create mode 100644 components/orgchart/PrintChart.tsx create mode 100644 lib/orgchart-print.ts create mode 100644 tests/components/PrintChart.test.tsx 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(); + }); +});