Print a checklist without printing the application around it
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:
79
lib/checklist-print-data.ts
Normal file
79
lib/checklist-print-data.ts
Normal 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
65
lib/checklist-print.ts
Normal 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user