Print a checklist without printing the application around it
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 6m6s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m22s

The checklist gets a print view: one A4 page, header carrying personnel
number, name, date of extract, entry date and position, the items in two
flowing columns so twenty-five fit, and two signature lines at the foot.
It prints the *state*, dated — not a blank form.

The default filename in the save dialog comes from the document title,
which is set to the agreed convention:

  20260818_2884_Aigner-Manuel_Onboarding-Checklist

Surname first, like everywhere else in the application, so a folder of
these sorts by person and within a person by date. Umlauts are resolved
rather than stripped: the naive route (NFKD, then every non-ASCII to a
dash) turns "Müller" into "Mu-ller", because decomposition splits the
umlaut and the diaeresis becomes the dash. "Weiß" needs its own rule —
it has no decomposition and would otherwise vanish.

The reported defect: the printout carried the application's own top bar
— hamburger, bell, "Neueinstellung", sign-out. Those are controls; on
paper they are decoration, and on a checklist filed in a personnel
record, misleading. The rule now sits on AppShell rather than on this
one page, so the org-chart print view — which had the same problem —
gets it too, along with anything printed later. The shell's padding goes
with it: the type area is set by @page on the print page itself, and the
shell's would have been added on top.

The browser's own header line (date, title, URL) is separate — that is a
checkbox in the print dialog, not something CSS can reach.

440 tests pass, including 13 new ones pinning the filename convention.
Not yet seen in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 20:19:58 +02:00
parent 521e4ecf00
commit 19e3170b00
9 changed files with 528 additions and 9 deletions

View File

@@ -0,0 +1,79 @@
import type { AufgabenStand } from "./checklist";
import type { ChecklistArt } from "./checklist-print";
import type { Tx } from "./db";
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
import { todayIso } from "./format";
import { besetzungenAbfrage, orgAsOf, pickPlacements } from "./placement";
// Was der Ausdruck einer Checkliste braucht — und nur das.
//
// Nicht loadEmployeeDetail: das holt offene Planstellen, Berichtslinien, den
// Organisationsbaum und die Historie, wovon der Ausdruck nichts zeigt. Eine
// eigene, schmale Abfrage in einer Rundreise (lib/db/json.ts).
export type ChecklistKopf = {
personnel_number: number;
first_name: string;
last_name: string;
entry_date: string;
/** Die Bezeichnung der Person; kann von der Planstelle abweichen. */
job_title: string;
/** Aus der laufenden Besetzung — bei einer Person ohne Planstelle leer. */
position_number: string | null;
position_title: string | null;
};
export type ChecklistDruckDaten = {
kopf: ChecklistKopf;
staende: AufgabenStand[];
};
export async function loadChecklistPrint(
tx: Tx,
employeeId: string,
art: ChecklistArt,
today: string = todayIso()
): Promise<ChecklistDruckDaten | null> {
const tabelle = art === "offboarding" ? "offboarding_tasks" : "onboarding_tasks";
const gelesen = await tx
.selectNoFrom((eb) => [
jsonObjectFrom(
eb
.selectFrom("employees")
.select(["personnel_number", "first_name", "last_name", "entry_date", "exit_date", "job_title"])
.where("id", "=", employeeId)
).as("person"),
jsonArrayFrom(besetzungenAbfrage(eb, [employeeId])).as("placementRows"),
jsonArrayFrom(
eb
.selectFrom(tabelle)
.select(["item_key", "erledigt", "wert", "kommentar", "updated_by_name"])
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
.where("employee_id", "=", employeeId)
).as("staende"),
])
.executeTakeFirstOrThrow();
const person = gelesen.person;
if (!person) return null;
// Derselbe Stichtag wie in der Akte: bei einer ausgetretenen Person ist die
// Besetzung am Austrittstag beendet, und zu heute gefragt käme keine heraus
// — der Kopf stünde dann ohne Planstelle da, obwohl es eine gab.
const asOf = orgAsOf(person, today);
const placement = pickPlacements(gelesen.placementRows as never, asOf).get(employeeId) ?? null;
return {
kopf: {
personnel_number: person.personnel_number,
first_name: person.first_name,
last_name: person.last_name,
entry_date: person.entry_date,
job_title: person.job_title,
position_number: placement?.positionNumber ?? null,
position_title: placement?.jobTitle ?? null,
},
staende: gelesen.staende as AufgabenStand[],
};
}

65
lib/checklist-print.ts Normal file
View File

@@ -0,0 +1,65 @@
// Namensgebung und Kopfdaten für den Ausdruck einer Checkliste.
//
// Reine Funktionen, ohne Datenbank und ohne React — damit die Konvention
// geprüft werden kann, ohne eine Seite zu bauen.
export type ChecklistArt = "onboarding" | "offboarding";
export const CHECKLIST_ARTEN: readonly ChecklistArt[] = ["onboarding", "offboarding"];
export function parseArt(wert: string | undefined): ChecklistArt {
return wert === "offboarding" ? "offboarding" : "onboarding";
}
/** Wie die Liste im Titel und im Dateinamen heisst. */
export function artLabel(art: ChecklistArt): string {
return art === "offboarding" ? "Offboarding-Checklist" : "Onboarding-Checklist";
}
export function artTitel(art: ChecklistArt): string {
return art === "offboarding" ? "Offboarding-Checkliste" : "Onboarding-Checkliste";
}
/**
* Umlaute und Sonderzeichen in etwas, das in jedem Dateisystem und in jedem
* Skript überlebt.
*
* Ein blosses NFKD mit anschliessendem Ersetzen aller Nicht-ASCII-Zeichen
* würde aus „Müller" ein „Mu-ller" machen: die Zerlegung trennt den Umlaut in
* `u` + kombinierenden Trema, und der Trema wird dann zum Trennstrich. Deshalb
* werden die kombinierenden Zeichen ausdrücklich entfernt, nicht ersetzt — und
* das scharfe S vorher zu „ss", weil es keine Zerlegung hat.
*/
export function dateiSicher(text: string): string {
return text
.replace(/ß/g, "ss")
.normalize("NFKD")
// \p{M} statt eines Zeichenbereichs: die kombinierenden Zeichen sind
// unsichtbar, und ein als Literal geschriebener Bereich könnte bei einem
// Kodierungswechsel stillschweigend kaputtgehen — dann fiele die Zeile aus
// und die nächste machte aus „Müller" ein „Mu-ller", ohne zu scheitern.
.replace(/\p{M}/gu, "")
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* `YYYYMMDD_PersNummer_Name_Onboarding-Checklist`
*
* Der Name steht als `Nachname-Vorname` darin, in derselben Reihenfolge, in
* der die Anwendung ihn überall anzeigt — so sortiert sich ein Ordner voller
* solcher Dateien von selbst nach Person, und innerhalb einer Person nach
* Datum, weil das Datum vorne steht.
*
* Das Datum ist das des Auszugs, nicht des Eintritts: der Dateiname sagt,
* wann dieser Stand gezogen wurde.
*/
export function checklistDateiname(
art: ChecklistArt,
person: { personnel_number: number; first_name: string; last_name: string },
auszugsdatum: string
): string {
const datum = auszugsdatum.slice(0, 10).replace(/-/g, "");
const name = dateiSicher(`${person.last_name}-${person.first_name}`);
return `${datum}_${person.personnel_number}_${name}_${artLabel(art)}`;
}