Baustein 6: Checklisten mit Durchlaeufen und PDF-Export

- Durchlauf aus einer Vorlage, mit Datum, Ticket und Kunde
- Schritt fuer Schritt abhaken, Bemerkung je Schritt
- Fortschritt gesamt und je Gruppe (Vorbereiten/Durchfuehren/Pruefen/Abschliessen)
- Abschliessen erst, wenn alles abgehakt ist; Meldung nennt die offenen Schritte
- Abgeschlossene Durchlaeufe sind gesperrt und bleiben als Nachweis
- Titel und Schritttexte werden beim Start kopiert, nicht verwiesen
- PDF-Export mit gezeichneten Ankreuzkaestchen und Unterschriftenzeilen
- Gemeinsame PDF-Grundlage fuer alle kuenftigen Ausgaben
- Ausbilder liest mit und darf das PDF abrufen, hakt aber nicht ab
- Abgeschlossene Durchlaeufe zaehlen im Wochenfortschritt mit

Behoben beim Durchstich: Die Fusszeile wurde unterhalb des Satzspiegels
gesetzt, wodurch PDFKit je Fusszeile eine leere Seite anhaengte - aus zwei
Seiten wurden sechs. Ein Test zaehlt jetzt die Seitenobjekte im PDF.

29 neue Tests, insgesamt 186.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 21:00:30 +02:00
co-authored by Claude Opus 5
parent ffbca01bdc
commit b9d6041f3b
41 changed files with 2074 additions and 5 deletions
+163
View File
@@ -0,0 +1,163 @@
import crypto from 'node:crypto';
import { and, desc, eq } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { heute, jetzt } from '../zeit';
import { laufstand, type Laufstand, type Schrittstand } from '../checklisten/fortschritt';
import type { Checkliste } from '../inhalte/schema';
import type { ChecklisteLauf, ChecklisteSchritt } from '../db/schema';
/** Checklisten-Durchläufe. Die Vorlage kommt aus der Datei, der Lauf aus der Datenbank. */
export function laeufeVon(benutzerId: string): ChecklisteLauf[] {
return datenbank()
.select()
.from(schema.checklisteLauf)
.where(eq(schema.checklisteLauf.benutzerId, benutzerId))
.orderBy(desc(schema.checklisteLauf.datum), desc(schema.checklisteLauf.gestartetAm))
.all();
}
export function laufNachId(id: string): ChecklisteLauf | null {
return datenbank().select().from(schema.checklisteLauf).where(eq(schema.checklisteLauf.id, id)).get() ?? null;
}
export function schritteVon(laufId: string): ChecklisteSchritt[] {
return datenbank()
.select()
.from(schema.checklisteSchritt)
.where(eq(schema.checklisteSchritt.laufId, laufId))
.all();
}
/** Rechnet den Stand eines Durchlaufs aus. */
export function standVon(laufId: string, titelJeGruppe?: Record<string, string>): Laufstand {
const schritte: Schrittstand[] = schritteVon(laufId).map((z) => ({
gruppeId: z.gruppeId,
schrittId: z.schrittId,
text: z.text,
reihenfolge: z.reihenfolge,
erledigt: z.erledigt === 1,
bemerkung: z.bemerkung,
}));
return laufstand(schritte, titelJeGruppe);
}
/**
* Startet einen Durchlauf aus einer Vorlage.
*
* Titel und Schritttexte werden kopiert, nicht verwiesen: Ein abgeschlossener
* Durchlauf ist ein Nachweis und muss auch dann noch lesbar sein, wenn die
* Vorlage im Repository später geändert wird.
*/
export function laufStarten(werte: {
benutzerId: string;
vorlage: Checkliste;
datum?: string;
ticket?: string | null;
kunde?: string | null;
}): ChecklisteLauf {
const db = datenbank();
const id = crypto.randomUUID();
const lauf: ChecklisteLauf = {
id,
benutzerId: werte.benutzerId,
checklisteId: werte.vorlage.id,
titel: werte.vorlage.titel,
datum: werte.datum || heute(),
ticket: werte.ticket?.trim() || null,
kunde: werte.kunde?.trim() || null,
bemerkung: null,
gestartetAm: jetzt(),
abgeschlossenAm: null,
};
const schritte: ChecklisteSchritt[] = [];
let reihenfolge = 0;
for (const gruppe of werte.vorlage.gruppen) {
for (const schritt of gruppe.schritte) {
schritte.push({
id: crypto.randomUUID(),
laufId: id,
gruppeId: gruppe.id,
schrittId: schritt.id,
text: schritt.text,
reihenfolge: (reihenfolge += 1),
erledigt: 0,
bemerkung: null,
erledigtAm: null,
});
}
}
// Drizzle führt die Transaktion sofort aus – anders als better-sqlite3,
// wo `transaction()` erst eine aufrufbare Funktion zurückgibt.
db.transaction((tx) => {
tx.insert(schema.checklisteLauf).values(lauf).run();
if (schritte.length > 0) tx.insert(schema.checklisteSchritt).values(schritte).run();
});
return lauf;
}
export function schrittSetzen(laufId: string, schrittId: string, erledigt: boolean): void {
datenbank()
.update(schema.checklisteSchritt)
.set({ erledigt: erledigt ? 1 : 0, erledigtAm: erledigt ? jetzt() : null })
.where(and(eq(schema.checklisteSchritt.laufId, laufId), eq(schema.checklisteSchritt.schrittId, schrittId)))
.run();
}
export function bemerkungSetzen(laufId: string, schrittId: string, bemerkung: string): void {
datenbank()
.update(schema.checklisteSchritt)
.set({ bemerkung: bemerkung.trim() || null })
.where(and(eq(schema.checklisteSchritt.laufId, laufId), eq(schema.checklisteSchritt.schrittId, schrittId)))
.run();
}
export function laufBemerkung(laufId: string, bemerkung: string): void {
datenbank()
.update(schema.checklisteLauf)
.set({ bemerkung: bemerkung.trim() || null })
.where(eq(schema.checklisteLauf.id, laufId))
.run();
}
export function laufKopfdaten(laufId: string, werte: { datum: string; ticket: string | null; kunde: string | null }): void {
datenbank()
.update(schema.checklisteLauf)
.set({ datum: werte.datum, ticket: werte.ticket?.trim() || null, kunde: werte.kunde?.trim() || null })
.where(eq(schema.checklisteLauf.id, laufId))
.run();
}
export function laufAbschliessen(laufId: string): void {
datenbank()
.update(schema.checklisteLauf)
.set({ abgeschlossenAm: jetzt() })
.where(eq(schema.checklisteLauf.id, laufId))
.run();
}
export function laufWiederOeffnen(laufId: string): void {
datenbank()
.update(schema.checklisteLauf)
.set({ abgeschlossenAm: null })
.where(eq(schema.checklisteLauf.id, laufId))
.run();
}
export function laufLoeschen(laufId: string): void {
datenbank().delete(schema.checklisteLauf).where(eq(schema.checklisteLauf.id, laufId)).run();
}
/** Wie viele Durchläufe der genannten Vorlagen hat dieser Benutzer abgeschlossen? */
export function anzahlAbgeschlossen(benutzerId: string, checklistenIds: string[]): number {
if (checklistenIds.length === 0) return 0;
const laeufe = laeufeVon(benutzerId);
const erledigte = new Set(
laeufe.filter((l) => l.abgeschlossenAm !== null).map((l) => l.checklisteId),
);
return checklistenIds.filter((id) => erledigte.has(id)).length;
}