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
+38
View File
@@ -26,6 +26,43 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
## [Unveröffentlicht]
## [0.6.0] – 2026-09-10
Checklisten mit Durchläufen – und der erste PDF-Export.
### Hinzugefügt
- Aus einer Checklistenvorlage wird ein **Durchlauf**, der zu einem Datum und
wahlweise zu einem Ticket und einem Kunden oder Bereich gehört
- Schritt für Schritt abhaken, je Schritt eine Bemerkung
- Fortschritt gesamt **und je Gruppe** (Vorbereiten, Durchführen, Prüfen,
Abschließen), jeweils als Balken mit Zahlen
- Ein Durchlauf lässt sich erst abschließen, wenn jeder Schritt abgehakt ist;
die Meldung nennt, wie viele Schritte noch offen sind
- Abgeschlossene Durchläufe sind gesperrt und lassen sich nicht löschen – sie
sind der Nachweis. Wieder öffnen geht, verwerfen nur, solange nichts abgehakt ist
- **PDF-Export** eines Durchlaufs: Kopfangaben, alle Gruppen mit gezeichneten
Ankreuzkästchen, Bemerkungen, Unterschriftenzeilen für Bearbeiter und
Ausbilder, Fußzeile mit Herkunft und Seitenzahl
- Gemeinsame PDF-Grundlage (`src/lib/pdf/grundlage.ts`) für alle künftigen
Ausgaben – das Berichtsheft setzt darauf auf
- Der Ausbilder sieht die Durchläufe seiner Azubis und kann das PDF abrufen,
hakt aber nichts für sie ab: Im Nachweis soll keine Arbeit stehen, die der
Azubi nicht gemacht hat
- Titel und Schritttexte werden beim Start aus der Vorlage **kopiert**. Ein
abgeschlossener Durchlauf bleibt damit lesbar, auch wenn die Vorlage im
Repository später geändert oder gelöscht wird
- Abgeschlossene Durchläufe zählen im Wochenfortschritt mit
- 29 Tests für Gruppenfortschritt, Abschlussregel, PDF-Erzeugung und
Zeichenvorrat
### Angemerkt
- Die PDF-Ausgabe nutzt die Standardschriften jedes Betrachters (Helvetica).
Das spart eine eingebettete Schriftdatei, beschränkt den Zeichenvorrat aber
auf WinAnsi. Umlaute, ß, €, § und ° sind enthalten; Zeichen wie ✓, ○ oder ▲
nicht. `fuerPdf()` ersetzt sie, Ankreuzkästchen und Haken werden **gezeichnet**
## [0.5.0] – 2026-09-10
Praxisaufgaben: bearbeiten, belegen, abnehmen.
@@ -230,6 +267,7 @@ anmelden und verwalten.
- `npm run konto:anlegen` für das erste Ausbilderkonto
[Unveröffentlicht]: #unveröffentlicht
[0.6.0]: #060--2026-09-10
[0.5.0]: #050--2026-09-10
[0.4.0]: #040--2026-09-10
[0.3.0]: #030--2026-09-10
+35
View File
@@ -7,6 +7,41 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
---
## 2026-09-10 · Baustein 6 – Checklisten und PDF
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 20:35 | 20:42 | 0:07 | Datenmodell, Migration, Schema |
| 20:42 | 20:48 | 0:06 | Gruppenfortschritt als reine Rechnung, 16 Tests |
| 20:48 | 20:52 | 0:04 | PDF-Grundlage und Checklisten-PDF |
| 20:52 | 21:00 | 0:08 | Repository, Aktionen, Seiten, PDF-Route |
| 21:00 | 21:10 | 0:10 | Durchstich; PDF gerendert und den Fußzeilenfehler gefunden |
| 21:10 | 21:20 | 0:10 | Fehler behoben, Regressionstest, Dokumente |
**Summe Baustein 6: rund 45 min**
### Was aufgefallen ist
- **Das PDF hatte sechs Seiten, vier davon leer.** Aufgefallen ist das erst
beim Ansehen – die Prüfung „Status 200, Content-Type application/pdf" war
grün. Ursache: Die Fußzeile wird unterhalb des Satzspiegels gesetzt, und
PDFKit hält das für überlaufenden Text und legt eine neue Seite an. Bei zwei
Seiten mal zwei Textaufrufen macht das vier leere Seiten. Behoben, indem der
untere Rand für die Dauer der Fußzeile auf 0 gesetzt wird. Ein Test zählt
jetzt die Seitenobjekte im PDF und hätte den Fehler gefunden.
- Headless-Chromium rendert keine PDFs. Für den Blick aufs Ergebnis musste
der Browser mit Fenster starten (`headless: false`) – auf diesem Rechner
geht das, auf einem Server nicht. Für die spätere Prüfstrecke heißt das:
Seitenzahl und Inhalt maschinell prüfen, das Aussehen von Hand.
- Titel und Schritttexte werden beim Start des Durchlaufs kopiert statt
verwiesen. Ein Nachweis, der sich rückwirkend ändert, wenn jemand die
Vorlage anfasst, wäre keiner.
- Der Ausbilder darf das PDF abrufen und mitlesen, aber nicht abhaken. Erst
beim Schreiben der Aktion ist mir aufgefallen, dass „darf sehen" und „darf
bearbeiten" hier auseinanderfallen – daher zwei getrennte Prüffunktionen.
---
## 2026-09-10 · Baustein 5 – Praxisaufgaben
| Von | Bis | Dauer | Tätigkeit |
+49
View File
@@ -208,3 +208,52 @@ Der Abstand ist Absicht: Eine zu große Datei soll die **eigene**, verständlich
Meldung bekommen und nicht den Rumpfgrößenfehler des Gerüsts, der nur im
Serverprotokoll auftaucht. Zusätzlich prüft der Browser vorab, damit am
Mobilfunk nicht erst 30 MB übertragen und dann abgelehnt werden.
## E23 · PDF mit Standardschriften statt eingebetteter Schrift
Die PDF-Ausgabe benutzt Helvetica, also eine der Schriften, die jeder
PDF-Betrachter mitbringt. Das spart eine eingebettete Schriftdatei im
Repository und hält die Dateien klein (ein Checklistenblatt wiegt rund 7 KB).
Der Preis ist der Zeichenvorrat **WinAnsi**. Enthalten sind alle deutschen
Umlaute, ß, €, § und °. Nicht enthalten sind Zeichen wie ✓, ○, ▲ oder Emoji.
Deshalb:
- `fuerPdf()` ersetzt typografische Feinheiten (Anführungszeichen,
Auslassungspunkte, geschützte Leerzeichen) und wirft nicht Darstellbares weg.
- Ankreuzkästchen und Haken werden **gezeichnet**, nicht gesetzt. Das sieht in
jedem Betrachter gleich aus und ist unabhängig von der Schrift.
Sollte später ein Zeichen außerhalb von WinAnsi wirklich gebraucht werden, ist
der Weg das Einbetten einer freien Schrift – nicht das Aufweichen dieser Regel.
## E24 · Fußzeilen brauchen einen aufgehobenen unteren Rand
PDFKit legt eine neue Seite an, sobald Text unterhalb des Satzspiegels
geschrieben wird. Eine Fußzeile steht dort per Definition. Ohne Gegenmaßnahme
entsteht je Seite und je Textaufruf eine leere Folgeseite.
`fusszeilen()` setzt deshalb `page.margins.bottom` kurzzeitig auf 0 und stellt
den Wert danach wieder her. Ein Test zählt die Seitenobjekte im erzeugten PDF –
damit fällt ein Rückfall sofort auf.
## E25 · Ein Durchlauf kopiert die Texte seiner Vorlage
`checkliste_lauf.titel` und `checkliste_schritt.text` werden beim Start aus der
Vorlage übernommen, statt zur Anzeigezeit nachgeschlagen zu werden.
Grund: Ein abgeschlossener Durchlauf ist ein Nachweis. Er muss auch in zwei
Jahren noch dasselbe aussagen – auch wenn die Vorlage unter `content/`
inzwischen umformuliert, erweitert oder gelöscht wurde. Die Seite zeigt einen
Hinweis, wenn es die Vorlage nicht mehr gibt, bleibt aber vollständig lesbar.
## E26 · Mitlesen und Bearbeiten sind getrennte Rechte
Beim Checklisten-Durchlauf fallen die beiden Rechte auseinander:
- **Lesen** darf der Eigentümer und sein Ausbilder (`laufZumLesen`).
- **Bearbeiten** darf nur der Eigentümer (`laufZumBearbeiten`).
Der Ausbilder hakt also nichts für seinen Azubi ab. Im Nachweis soll keine
Arbeit stehen, die der Azubi nicht selbst gemacht hat. Für die Abnahme gibt es
die Praxisaufgaben – dort ist es genau andersherum.
+4 -3
View File
@@ -9,9 +9,10 @@ und – der wichtigste Teil – den gesetzlich vorgeschriebenen Ausbildungsnachw
Gebaut für **einen Ausbilder und ein bis drei Auszubildende**. Kein
Lernplattform-Baukasten, keine Mandanten, keine Cloud.
> **Stand:** Bausteine 1 bis 5 von 10 sind fertig – Fundament, Inhalte,
> Wochenplan, Merkkarten, Praxisaufgaben. Der [Fahrplan](ROADMAP.md) zeigt,
> was folgt, das [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
> **Stand:** Bausteine 1 bis 6 von 10 sind fertig – Fundament, Inhalte,
> Wochenplan, Merkkarten, Praxisaufgaben, Checklisten. Der
> [Fahrplan](ROADMAP.md) zeigt, was folgt, das
> [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
---
+8 -1
View File
@@ -64,7 +64,14 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
- [x] Anhänge nur über Kennung, Berechtigung bei jedem Abruf geprüft
- [x] „Was auf dich wartet" zeigt offene Abnahmen
- [x] Aufgaben zählen im Wochenfortschritt mit
- [ ] **B6 · Checklisten mit Durchläufen und PDF**
- [x] **B6 · Checklisten mit Durchläufen und PDF**
- [x] Durchlauf aus einer Vorlage, mit Datum, Ticket und Kunde
- [x] Schritt für Schritt abhaken, Bemerkung je Schritt
- [x] Fortschritt je Gruppe (Vorbereiten / Durchführen / Prüfen / Abschließen)
- [x] Abschließen erst, wenn alles abgehakt ist
- [x] Abgeschlossene Durchläufe bleiben als Nachweis und sind gesperrt
- [x] PDF-Export mit Unterschriftenzeilen
- [x] Ausbilder liest mit, hakt aber nicht für den Azubi ab
- [ ] **B7 · Berichtsheft: Tagesnotizen, Vorschläge, Prüfung, Abzeichnen, PDF**
## 0.5 – Bewertung
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

+11
View File
@@ -118,6 +118,17 @@ async function main() {
await seite.waitForSelector('h1');
await bild(seite, '17-aufgabe-azubi');
await seite.goto(`${BASIS}/checklisten`);
await seite.waitForSelector('h1');
await bild(seite, '18-checklisten');
const lauf = await seite.$('.laufzeile-haupt');
if (lauf) {
await lauf.click();
await seite.waitForSelector('.schrittliste');
await bild(seite, '19-checklisten-lauf');
}
await seite.goto(`${BASIS}/glossar`);
await seite.waitForSelector('h1');
await bild(seite, '15-glossar');
@@ -0,0 +1,174 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import {
durchlaufAbschliessen,
durchlaufStarten,
kopfdatenSpeichern,
schrittBemerkung,
type Rueckmeldung,
} from './aktionen';
export function StartFormular({
checklisteId,
mitTicket,
heuteDatum,
}: {
checklisteId: string;
mitTicket: boolean;
heuteDatum: string;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(durchlaufStarten, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="checklisteId" value={checklisteId} />
<div className="reihe" style={{ gap: '0.4rem', alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 130px' }}>
<label htmlFor={`datum-${checklisteId}`}>Datum</label>
<input id={`datum-${checklisteId}`} name="datum" type="date" defaultValue={heuteDatum} required />
</div>
{mitTicket ? (
<div style={{ flex: '1 1 110px' }}>
<label htmlFor={`ticket-${checklisteId}`}>Ticket</label>
<input id={`ticket-${checklisteId}`} name="ticket" type="text" maxLength={80} placeholder="z. B. 4711" />
</div>
) : null}
</div>
<div className="feld" style={{ marginTop: 0 }}>
<label htmlFor={`kunde-${checklisteId}`}>Kunde oder Bereich</label>
<input
id={`kunde-${checklisteId}`}
name="kunde"
type="text"
maxLength={120}
placeholder="z. B. Buchhaltung, Halle 2"
/>
</div>
<div>
<Absenden className="knopf knopf-akzent">Durchlauf starten</Absenden>
</div>
</form>
);
}
export function KopfdatenFormular({
laufId,
datum,
ticket,
kunde,
bemerkung,
gesperrt,
}: {
laufId: string;
datum: string;
ticket: string;
kunde: string;
bemerkung: string;
gesperrt: boolean;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(kopfdatenSpeichern, {});
if (gesperrt) return null;
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="laufId" value={laufId} />
<div className="reihe" style={{ alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 140px' }}>
<label htmlFor="lauf-datum">Datum</label>
<input id="lauf-datum" name="datum" type="date" defaultValue={datum} required />
</div>
<div style={{ flex: '1 1 120px' }}>
<label htmlFor="lauf-ticket">Ticket</label>
<input id="lauf-ticket" name="ticket" type="text" maxLength={80} defaultValue={ticket} />
</div>
<div style={{ flex: '2 1 200px' }}>
<label htmlFor="lauf-kunde">Kunde oder Bereich</label>
<input id="lauf-kunde" name="kunde" type="text" maxLength={120} defaultValue={kunde} />
</div>
</div>
<div className="feld" style={{ marginTop: 0 }}>
<label htmlFor="lauf-bemerkung">Bemerkung zum ganzen Durchlauf</label>
<textarea
id="lauf-bemerkung"
name="bemerkung"
rows={3}
maxLength={2000}
defaultValue={bemerkung}
placeholder="Besonderheiten, Abweichungen, was beim nächsten Mal anders laufen sollte."
/>
</div>
<div>
<Absenden className="knopf knopf-zweit">Angaben speichern</Absenden>
</div>
</form>
);
}
export function BemerkungFormular({
laufId,
schrittId,
bemerkung,
gesperrt,
}: {
laufId: string;
schrittId: string;
bemerkung: string;
gesperrt: boolean;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(schrittBemerkung, {});
if (gesperrt) {
return bemerkung ? <p className="schritt-bemerkung">{bemerkung}</p> : null;
}
return (
<details className="schritt-notiz" open={Boolean(bemerkung)}>
<summary>{bemerkung ? 'Bemerkung' : 'Bemerkung hinzufügen'}</summary>
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="schrittId" value={schrittId} />
<label htmlFor={`bem-${schrittId}`} className="nur-lesehilfe">
Bemerkung zu diesem Schritt
</label>
<textarea
id={`bem-${schrittId}`}
name="bemerkung"
rows={2}
maxLength={1000}
defaultValue={bemerkung}
placeholder="z. B. Netzteil auffällig, Ersatz bestellt"
/>
<div>
<Absenden className="knopf knopf-still">Speichern</Absenden>
</div>
</form>
</details>
);
}
export function AbschlussFormular({ laufId, offen }: { laufId: string; offen: number }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(durchlaufAbschliessen, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="laufId" value={laufId} />
<Absenden className="knopf knopf-akzent">
{offen === 0 ? 'Durchlauf abschließen' : `Abschließen (noch ${offen} offen)`}
</Absenden>
</form>
);
}
@@ -0,0 +1,166 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { ausbildungVonAzubi } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import {
bemerkungSetzen,
laufAbschliessen,
laufBemerkung,
laufKopfdaten,
laufLoeschen,
laufNachId,
laufStarten,
laufWiederOeffnen,
schrittSetzen,
standVon,
} from '@/lib/repos/checklisten';
import { darfAbschliessen } from '@/lib/checklisten/fortschritt';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/**
* Holt einen Durchlauf und prüft, ob der Anmelder ihn **bearbeiten** darf.
*
* Bearbeiten darf nur, wem der Durchlauf gehört. Der Ausbilder darf mitlesen
* (siehe `laufZumLesen`), aber nicht für seinen Azubi abhaken – sonst stünde
* im Nachweis eine Arbeit, die der Azubi nie gemacht hat.
*/
async function laufZumBearbeiten(laufId: string) {
const benutzer = await erfordereBenutzer();
const lauf = laufNachId(laufId);
if (!lauf) throw new Error('Diesen Durchlauf gibt es nicht.');
if (lauf.benutzerId !== benutzer.id) throw new Error('Kein Zugriff auf diesen Durchlauf.');
return { benutzer, lauf };
}
/** Zum Lesen: der eigene Durchlauf oder der eines betreuten Azubis. */
export async function laufZumLesen(laufId: string) {
const benutzer = await erfordereBenutzer();
const lauf = laufNachId(laufId);
if (!lauf) return null;
const bezug = ausbildungVonAzubi(lauf.benutzerId);
if (!darfAzubiDatenSehen(benutzer, lauf.benutzerId, bezug)) return null;
return { benutzer, lauf };
}
function aktualisiere(laufId: string) {
revalidatePath('/checklisten');
revalidatePath(`/checklisten/lauf/${laufId}`);
revalidatePath('/');
}
export async function durchlaufStarten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') {
return { fehler: 'Durchläufe führt der Azubi – der Ausbilder liest mit.' };
}
const checklisteId = String(formular.get('checklisteId') ?? '');
const vorlage = inhalte().checklisteNach.get(checklisteId);
if (!vorlage) return { fehler: 'Diese Checkliste gibt es nicht.' };
const datum = String(formular.get('datum') ?? '').trim();
if (!datumMuster.test(datum)) return { fehler: 'Bitte ein gültiges Datum wählen.' };
const ticket = String(formular.get('ticket') ?? '');
const kunde = String(formular.get('kunde') ?? '');
if (ticket.length > 80) return { fehler: 'Die Ticketangabe ist zu lang.' };
if (kunde.length > 120) return { fehler: 'Die Kundenangabe ist zu lang.' };
const lauf = laufStarten({ benutzerId: benutzer.id, vorlage, datum, ticket, kunde });
revalidatePath('/checklisten');
redirect(`/checklisten/lauf/${lauf.id}`);
}
export async function schrittUmschalten(formular: FormData): Promise<void> {
const laufId = String(formular.get('laufId') ?? '');
const schrittId = String(formular.get('schrittId') ?? '');
const erledigt = String(formular.get('erledigt') ?? '') === '1';
const { lauf } = await laufZumBearbeiten(laufId);
if (lauf.abgeschlossenAm) return; // abgeschlossene Durchläufe sind Nachweis, nicht Arbeitsblatt
schrittSetzen(laufId, schrittId, erledigt);
aktualisiere(laufId);
}
export async function schrittBemerkung(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const laufId = String(formular.get('laufId') ?? '');
const schrittId = String(formular.get('schrittId') ?? '');
const bemerkung = String(formular.get('bemerkung') ?? '');
const { lauf } = await laufZumBearbeiten(laufId);
if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist abgeschlossen.' };
if (bemerkung.length > 1000) return { fehler: 'Die Bemerkung ist zu lang – höchstens 1000 Zeichen.' };
bemerkungSetzen(laufId, schrittId, bemerkung);
aktualisiere(laufId);
return { erfolg: bemerkung.trim() ? 'Bemerkung gespeichert.' : 'Bemerkung entfernt.' };
}
export async function kopfdatenSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const laufId = String(formular.get('laufId') ?? '');
const { lauf } = await laufZumBearbeiten(laufId);
if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist abgeschlossen.' };
const datum = String(formular.get('datum') ?? '').trim();
if (!datumMuster.test(datum)) return { fehler: 'Bitte ein gültiges Datum wählen.' };
const bemerkung = String(formular.get('bemerkung') ?? '');
if (bemerkung.length > 2000) return { fehler: 'Die Bemerkung ist zu lang – höchstens 2000 Zeichen.' };
laufKopfdaten(laufId, {
datum,
ticket: String(formular.get('ticket') ?? ''),
kunde: String(formular.get('kunde') ?? ''),
});
laufBemerkung(laufId, bemerkung);
aktualisiere(laufId);
return { erfolg: 'Angaben gespeichert.' };
}
export async function durchlaufAbschliessen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const laufId = String(formular.get('laufId') ?? '');
const { lauf } = await laufZumBearbeiten(laufId);
if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist schon abgeschlossen.' };
const stand = standVon(laufId);
if (!darfAbschliessen(stand)) {
const offen = stand.gesamt - stand.erledigt;
return {
fehler: `Es ${offen === 1 ? 'ist noch ein Schritt' : `sind noch ${offen} Schritte`} offen. Ein Durchlauf wird erst abgeschlossen, wenn alles abgehakt ist.`,
};
}
laufAbschliessen(laufId);
aktualisiere(laufId);
return { erfolg: 'Durchlauf abgeschlossen. Er bleibt als Nachweis erhalten.' };
}
export async function durchlaufWiederOeffnen(formular: FormData): Promise<void> {
const laufId = String(formular.get('laufId') ?? '');
await laufZumBearbeiten(laufId);
laufWiederOeffnen(laufId);
aktualisiere(laufId);
}
export async function durchlaufVerwerfen(formular: FormData): Promise<void> {
const laufId = String(formular.get('laufId') ?? '');
const { lauf } = await laufZumBearbeiten(laufId);
// Ein abgeschlossener Durchlauf ist ein Nachweis und wird nicht gelöscht.
if (lauf.abgeschlossenAm) return;
laufLoeschen(laufId);
revalidatePath('/checklisten');
redirect('/checklisten');
}
@@ -0,0 +1,233 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { inhalte } from '@/lib/inhalte';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { standVon } from '@/lib/repos/checklisten';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { AbschlussFormular, BemerkungFormular, KopfdatenFormular } from '../../Formulare';
import { durchlaufVerwerfen, durchlaufWiederOeffnen, laufZumLesen, schrittUmschalten } from '../../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ laufId: string }> }): Promise<Metadata> {
const { laufId } = await params;
const gelesen = await laufZumLesen(laufId);
return { title: gelesen ? gelesen.lauf.titel : 'Durchlauf' };
}
export default async function LaufSeite({ params }: { params: Promise<{ laufId: string }> }) {
const { laufId } = await params;
// Enthält die Rechteprüfung: Wer nicht darf, bekommt null – und damit 404.
const gelesen = await laufZumLesen(laufId);
if (!gelesen) notFound();
const { benutzer, lauf } = gelesen;
const eigener = lauf.benutzerId === benutzer.id;
const gesperrt = Boolean(lauf.abgeschlossenAm) || !eigener;
const alle = inhalte();
const vorlage = alle.checklisteNach.get(lauf.checklisteId);
const stand = standVon(laufId);
const bearbeiter = eigener ? benutzer : benutzerNachId(lauf.benutzerId);
const positionen = (vorlage?.rahmenplan ?? [])
.map((p) => alle.positionNach.get(p))
.filter((p) => p !== undefined);
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/checklisten">Checklisten</Link>
{!eigener && bearbeiter ? ` › ${bearbeiter.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
{lauf.abgeschlossenAm ? (
<span className="marke-status" data-ton="gut">
✓ abgeschlossen am {alsZeitpunktText(lauf.abgeschlossenAm)}
</span>
) : (
<span className="marke-status" data-ton="warnung">
◐ in Arbeit
</span>
)}
<span className="leise">
{alsDatumText(lauf.datum)}
{lauf.ticket ? ` · Ticket ${lauf.ticket}` : ''}
{lauf.kunde ? ` · ${lauf.kunde}` : ''}
</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{lauf.titel}</h1>
{vorlage ? <p className="handout-worum">{vorlage.worum}</p> : null}
{bearbeiter && !eigener ? <p className="leise">Bearbeitet von {bearbeiter.name}</p> : null}
</header>
{!vorlage ? (
<p className="hinweis hinweis-warnung">
Die Vorlage <code>{lauf.checklisteId}</code> gibt es im Repository nicht mehr. Der Durchlauf bleibt
vollständig lesbar – Titel und Schritttexte wurden beim Start mitkopiert.
</p>
) : null}
{positionen.length > 0 ? (
<div className="handout-zuordnung karte">
<dl>
<div>
<dt>Ausbildungsrahmenplan</dt>
<dd>
{positionen.map((p) => (
<span key={p.id} className="zuordnung-marke" title={`${p.titel} (${p.verordnung})`}>
<strong>{p.id}</strong> {p.kurz}
</span>
))}
</dd>
</div>
</dl>
</div>
) : null}
<section className="karte stapel-eng" aria-labelledby="gesamtstand">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="gesamtstand" style={{ margin: 0, fontSize: '1rem' }}>
Fortschritt
</h2>
<span className="leise">
{stand.erledigt} von {stand.gesamt} Schritten · {stand.prozent} %
</span>
</div>
<div
className="balken"
role="progressbar"
aria-valuenow={stand.erledigt}
aria-valuemin={0}
aria-valuemax={stand.gesamt}
aria-label={`${stand.erledigt} von ${stand.gesamt} Schritten erledigt`}
>
<span style={{ width: `${stand.prozent}%` }} />
</div>
<ul className="gruppenstand">
{stand.gruppen.map((gruppe) => (
<li key={gruppe.gruppeId}>
<span>{gruppe.titel}</span>
<span className="balken" style={{ width: '70px' }}>
<span style={{ width: `${gruppe.prozent}%` }} />
</span>
<span className="leise">
{gruppe.erledigt}/{gruppe.gesamt}
</span>
</li>
))}
</ul>
</section>
{stand.gruppen.map((gruppe) => (
<section key={gruppe.gruppeId} className="stapel-eng" aria-labelledby={`g-${gruppe.gruppeId}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id={`g-${gruppe.gruppeId}`} style={{ margin: 0 }}>
{gruppe.titel}
</h2>
<span className={`marke-status${gruppe.vollstaendig ? '' : ''}`} data-ton={gruppe.vollstaendig ? 'gut' : 'neutral'}>
{gruppe.vollstaendig ? '✓' : '○'} {gruppe.erledigt} von {gruppe.gesamt}
</span>
</div>
<ul className="schrittliste">
{gruppe.schritte.map((schritt) => (
<li key={schritt.schrittId} data-erledigt={schritt.erledigt ? 'ja' : 'nein'}>
<form action={schrittUmschalten} className="schritt-haken">
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="schrittId" value={schritt.schrittId} />
<input type="hidden" name="erledigt" value={schritt.erledigt ? '0' : '1'} />
<button
type="submit"
className="hakenknopf"
disabled={gesperrt}
aria-pressed={schritt.erledigt}
title={gesperrt ? 'Gesperrt' : schritt.erledigt ? 'Haken entfernen' : 'Als erledigt abhaken'}
>
<span aria-hidden="true">{schritt.erledigt ? '✓' : ''}</span>
<span className="nur-lesehilfe">
{schritt.text} – {schritt.erledigt ? 'erledigt' : 'offen'}
</span>
</button>
</form>
<div className="schritt-inhalt">
<span className="schritt-text">{schritt.text}</span>
<BemerkungFormular
laufId={laufId}
schrittId={schritt.schrittId}
bemerkung={schritt.bemerkung ?? ''}
gesperrt={gesperrt}
/>
</div>
</li>
))}
</ul>
</section>
))}
{eigener && !lauf.abgeschlossenAm ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="angaben">
<h2 id="angaben" style={{ marginBottom: 0 }}>
Angaben zum Durchlauf
</h2>
<KopfdatenFormular
laufId={laufId}
datum={lauf.datum}
ticket={lauf.ticket ?? ''}
kunde={lauf.kunde ?? ''}
bemerkung={lauf.bemerkung ?? ''}
gesperrt={false}
/>
</section>
) : lauf.bemerkung ? (
<section className="karte stapel-eng" aria-labelledby="bemerkung">
<h2 id="bemerkung" style={{ marginBottom: 0, fontSize: '1rem' }}>
Bemerkung zum Durchlauf
</h2>
<p className="notiz-anzeige">{lauf.bemerkung}</p>
</section>
) : null}
<div className="reihe nicht-drucken">
<a href={`/checklisten/lauf/${laufId}/pdf`} className="knopf knopf-zweit" target="_blank" rel="noreferrer">
Als PDF öffnen
</a>
{eigener && !lauf.abgeschlossenAm ? (
<AbschlussFormular laufId={laufId} offen={stand.gesamt - stand.erledigt} />
) : null}
{eigener && lauf.abgeschlossenAm ? (
<form action={durchlaufWiederOeffnen}>
<input type="hidden" name="laufId" value={laufId} />
<button type="submit" className="knopf knopf-still">
Wieder öffnen
</button>
</form>
) : null}
{eigener && !lauf.abgeschlossenAm && stand.erledigt === 0 ? (
<form action={durchlaufVerwerfen}>
<input type="hidden" name="laufId" value={laufId} />
<button type="submit" className="knopf knopf-still">
Verwerfen
</button>
</form>
) : null}
</div>
{lauf.abgeschlossenAm ? (
<p className="leise" style={{ marginBottom: 0 }}>
Abgeschlossene Durchläufe bleiben als Nachweis erhalten und lassen sich nicht löschen.
</p>
) : null}
</article>
);
}
@@ -0,0 +1,55 @@
import { inhalte } from '@/lib/inhalte';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { standVon } from '@/lib/repos/checklisten';
import { checklistenPdf } from '@/lib/pdf/checkliste';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { laufZumLesen } from '../../../aktionen';
export const dynamic = 'force-dynamic';
/**
* Der Durchlauf als PDF.
*
* Die Rechteprüfung steckt in `laufZumLesen` – dieselbe Funktion, die auch die
* Seite benutzt. Wer nicht darf, bekommt 404 statt 403: Ob es den Durchlauf
* gibt, geht einen Unbefugten nichts an.
*/
export async function GET(_anfrage: Request, kontext: { params: Promise<{ laufId: string }> }) {
const { laufId } = await kontext.params;
const gelesen = await laufZumLesen(laufId);
if (!gelesen) return new Response('Nicht gefunden.', { status: 404 });
const { lauf } = gelesen;
const vorlage = inhalte().checklisteNach.get(lauf.checklisteId);
const bearbeiter = benutzerNachId(lauf.benutzerId);
const stand = standVon(laufId);
const pdf = await checklistenPdf(
{
titel: lauf.titel,
checklisteId: lauf.checklisteId,
bearbeiter: bearbeiter?.name ?? 'unbekannt',
datum: alsDatumText(lauf.datum),
ticket: lauf.ticket,
kunde: lauf.kunde,
bemerkung: lauf.bemerkung,
gestartetAm: alsZeitpunktText(lauf.gestartetAm),
abgeschlossenAm: lauf.abgeschlossenAm ? alsZeitpunktText(lauf.abgeschlossenAm) : null,
worum: vorlage?.worum,
},
stand,
);
const dateiname = `Checkliste_${lauf.checklisteId}_${lauf.datum}.pdf`.replace(/[^\w.\-]/g, '_');
return new Response(new Uint8Array(pdf), {
headers: {
'Content-Type': 'application/pdf',
'Content-Length': String(pdf.byteLength),
'Content-Disposition': `inline; filename="${dateiname}"`,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
});
}
+159
View File
@@ -0,0 +1,159 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import { laeufeVon, standVon } from '@/lib/repos/checklisten';
import { alsDatumText, alsZeitpunktText, heute } from '@/lib/zeit';
import { StartFormular } from './Formulare';
export const metadata: Metadata = { title: 'Checklisten' };
export const dynamic = 'force-dynamic';
export default async function ChecklistenSeite() {
const benutzer = await erfordereBenutzer();
const alle = inhalte();
const istAzubi = benutzer.rolle === 'azubi';
// Der Ausbilder sieht die Durchläufe seiner Azubis, der Azubi seine eigenen.
const beteiligte = istAzubi
? [{ id: benutzer.id, name: benutzer.name }]
: ausbildungenVonAusbilder(benutzer.id).map(({ azubi }) => ({ id: azubi.id, name: azubi.name }));
return (
<div className="stapel">
<div>
<h1>Checklisten</h1>
<p className="leise">
{alle.checklisten.length} Vorlagen für wiederkehrende Arbeiten. Aus einer Vorlage wird ein Durchlauf –
und der bleibt als Nachweis erhalten.
</p>
</div>
<section className="stapel" aria-labelledby="vorlagen">
<h2 id="vorlagen">Vorlagen</h2>
<div className="raster">
{alle.checklisten.map((vorlage) => {
const schritte = vorlage.gruppen.reduce((summe, g) => summe + g.schritte.length, 0);
const block = alle.blockNach.get(vorlage.block);
return (
<section key={vorlage.id} className="karte stapel-eng">
<div>
<h3 style={{ fontSize: '1rem', marginBottom: '0.15rem' }}>{vorlage.titel}</h3>
<p className="leise" style={{ margin: 0 }}>
{schritte} Schritte in {vorlage.gruppen.length} Gruppen
{block ? ` · ${block.titel}` : ''}
</p>
</div>
<p style={{ margin: 0, fontSize: '0.93rem' }}>{vorlage.worum}</p>
{istAzubi ? (
<StartFormular checklisteId={vorlage.id} mitTicket={vorlage.mit_ticket} heuteDatum={heute()} />
) : (
<p className="leise" style={{ margin: 0 }}>
Quelle: <code>content/checklisten/{vorlage.id}.yaml</code>
</p>
)}
</section>
);
})}
</div>
</section>
{beteiligte.map((person) => {
const laeufe = laeufeVon(person.id);
const offene = laeufe.filter((l) => !l.abgeschlossenAm);
const fertige = laeufe.filter((l) => l.abgeschlossenAm);
return (
<section key={person.id} className="stapel" aria-labelledby={`laeufe-${person.id}`}>
<h2 id={`laeufe-${person.id}`}>
{istAzubi ? 'Deine Durchläufe' : `Durchläufe von ${person.name}`}
</h2>
{laeufe.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch kein Durchlauf gestartet.
</p>
) : (
<>
{offene.length > 0 ? (
<div className="stapel-eng">
<h3 style={{ fontSize: '0.95rem', marginBottom: 0 }}>Offen</h3>
{offene.map((lauf) => (
<Laufzeile key={lauf.id} laufId={lauf.id} titel={lauf.titel} datum={lauf.datum}
ticket={lauf.ticket} kunde={lauf.kunde} abgeschlossenAm={null} />
))}
</div>
) : null}
{fertige.length > 0 ? (
<div className="stapel-eng">
<h3 style={{ fontSize: '0.95rem', marginBottom: 0 }}>
Abgeschlossen ({fertige.length})
</h3>
{fertige.map((lauf) => (
<Laufzeile key={lauf.id} laufId={lauf.id} titel={lauf.titel} datum={lauf.datum}
ticket={lauf.ticket} kunde={lauf.kunde} abgeschlossenAm={lauf.abgeschlossenAm} />
))}
</div>
) : null}
</>
)}
</section>
);
})}
</div>
);
}
function Laufzeile({
laufId,
titel,
datum,
ticket,
kunde,
abgeschlossenAm,
}: {
laufId: string;
titel: string;
datum: string;
ticket: string | null;
kunde: string | null;
abgeschlossenAm: string | null;
}) {
const stand = standVon(laufId);
return (
<div className="laufzeile">
<Link href={`/checklisten/lauf/${laufId}`} className="laufzeile-haupt">
<span className="laufzeile-titel">{titel}</span>
<span className="leise">
{alsDatumText(datum)}
{ticket ? ` · Ticket ${ticket}` : ''}
{kunde ? ` · ${kunde}` : ''}
</span>
</Link>
<div className="laufzeile-stand">
<span className="leise">
{stand.erledigt} / {stand.gesamt}
</span>
<span className="balken" style={{ width: '90px' }}>
<span style={{ width: `${stand.prozent}%` }} />
</span>
{abgeschlossenAm ? (
<span className="marke-status" data-ton="gut" title={alsZeitpunktText(abgeschlossenAm)}>
✓ fertig
</span>
) : (
<span className="marke-status" data-ton="warnung">
◐ offen
</span>
)}
</div>
</div>
);
}
+2
View File
@@ -11,6 +11,7 @@ const fuerAzubi: Navigationseintrag[] = [
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/aufgaben', titel: 'Aufgaben' },
{ pfad: '/checklisten', titel: 'Checklisten' },
{ pfad: '/glossar', titel: 'Glossar' },
];
@@ -20,6 +21,7 @@ const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/aufgaben', titel: 'Aufgaben' },
{ pfad: '/checklisten', titel: 'Checklisten' },
{ pfad: '/glossar', titel: 'Glossar' },
{ pfad: '/verwaltung', titel: 'Verwaltung' },
];
+117
View File
@@ -1224,3 +1224,120 @@ input[type="file"] {
font: inherit;
min-height: 44px;
}
/* --- Checklisten --- */
.laufzeile {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
background: var(--flaeche);
border: 1px solid var(--rand-zart);
border-radius: var(--radius);
padding: 0.65rem 0.9rem;
}
.laufzeile-haupt {
flex: 1 1 240px;
display: grid;
gap: 0.1rem;
text-decoration: none;
color: var(--text);
}
.laufzeile-haupt:hover { color: var(--petrol); }
.laufzeile-titel { font-weight: 650; }
.laufzeile-stand {
display: flex;
align-items: center;
gap: 0.6rem;
}
.laufzeile-stand .balken { flex-shrink: 0; }
.gruppenstand { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.35rem; }
.gruppenstand li {
display: grid;
grid-template-columns: 1fr 70px 3rem;
align-items: center;
gap: 0.6rem;
font-size: 0.9rem;
}
.gruppenstand .leise { text-align: right; font-variant-numeric: tabular-nums; }
.schrittliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.35rem; }
.schrittliste li {
display: flex;
gap: 0.7rem;
align-items: flex-start;
background: var(--flaeche);
border: 1px solid var(--rand-zart);
border-radius: var(--radius-klein);
padding: 0.55rem 0.7rem;
}
.schrittliste li[data-erledigt="ja"] {
background: var(--gut-flaeche);
border-color: var(--gut);
}
.schrittliste li[data-erledigt="ja"] .schritt-text { color: var(--gut); }
.schritt-haken { flex-shrink: 0; }
.hakenknopf {
width: 30px;
height: 30px;
border-radius: var(--radius-klein);
border: 2px solid var(--rand);
background: var(--grund);
color: var(--gut);
font-size: 1.05rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
}
.hakenknopf:hover:not(:disabled) { border-color: var(--petrol); }
.hakenknopf[aria-pressed="true"] { border-color: var(--gut); background: var(--gut-flaeche); }
.hakenknopf:disabled { opacity: 0.55; cursor: not-allowed; }
.schritt-inhalt { flex: 1; display: grid; gap: 0.25rem; }
.schritt-text { line-height: 1.45; }
.schritt-notiz > summary {
cursor: pointer;
font-size: 0.85rem;
color: var(--text-leise);
list-style: none;
padding: 0.15rem 0;
min-height: 28px;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.schritt-notiz > summary::-webkit-details-marker { display: none; }
.schritt-notiz > summary::before { content: "+"; font-weight: 700; }
.schritt-notiz[open] > summary::before { content: "−"; }
.schritt-notiz > form { margin-top: 0.35rem; }
.schritt-bemerkung {
margin: 0;
font-size: 0.88rem;
font-style: italic;
color: var(--text-leise);
border-left: 3px solid var(--rand);
padding-left: 0.6rem;
}
@media (max-width: 640px) {
.hakenknopf { width: 38px; height: 38px; }
.laufzeile-stand { width: 100%; justify-content: space-between; }
.gruppenstand li { grid-template-columns: 1fr 60px 3rem; }
}
@media print {
.schritt-notiz > summary { display: none; }
.schritt-notiz[open] > form { display: none; }
.hakenknopf { border-color: #666; }
}
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';
import { anteil, darfAbschliessen, laufstand, type Schrittstand } from './fortschritt';
function schritt(gruppeId: string, nummer: number, erledigt = false): Schrittstand {
return {
gruppeId,
schrittId: `${gruppeId[0]}${nummer}`,
text: `Schritt ${nummer} in ${gruppeId}`,
reihenfolge: nummer,
erledigt,
bemerkung: null,
};
}
const beispiel: Schrittstand[] = [
schritt('vorbereiten', 1, true),
schritt('vorbereiten', 2, true),
schritt('durchfuehren', 3, true),
schritt('durchfuehren', 4),
schritt('durchfuehren', 5),
schritt('pruefen', 6),
schritt('abschliessen', 7),
];
describe('Fortschritt eines Durchlaufs', () => {
it('zählt gesamt richtig', () => {
const stand = laufstand(beispiel);
expect(stand.erledigt).toBe(3);
expect(stand.gesamt).toBe(7);
expect(stand.prozent).toBe(43);
expect(stand.vollstaendig).toBe(false);
});
it('rechnet je Gruppe getrennt', () => {
const stand = laufstand(beispiel);
const nachId = Object.fromEntries(stand.gruppen.map((g) => [g.gruppeId, g]));
expect(nachId.vorbereiten!.erledigt).toBe(2);
expect(nachId.vorbereiten!.gesamt).toBe(2);
expect(nachId.vorbereiten!.prozent).toBe(100);
expect(nachId.vorbereiten!.vollstaendig).toBe(true);
expect(nachId.durchfuehren!.erledigt).toBe(1);
expect(nachId.durchfuehren!.gesamt).toBe(3);
expect(nachId.durchfuehren!.vollstaendig).toBe(false);
expect(nachId.pruefen!.prozent).toBe(0);
});
it('hält die feste Reihenfolge der Gruppen ein, egal wie die Schritte ankommen', () => {
const gemischt = [...beispiel].reverse();
const stand = laufstand(gemischt);
expect(stand.gruppen.map((g) => g.gruppeId)).toEqual([
'vorbereiten',
'durchfuehren',
'pruefen',
'abschliessen',
]);
});
it('sortiert die Schritte innerhalb einer Gruppe nach ihrer Reihenfolge', () => {
const gemischt = [schritt('durchfuehren', 5), schritt('durchfuehren', 3), schritt('durchfuehren', 4)];
const stand = laufstand(gemischt);
expect(stand.gruppen[0]!.schritte.map((s) => s.reihenfolge)).toEqual([3, 4, 5]);
});
it('lässt Gruppen weg, für die es keine Schritte gibt', () => {
const nurZwei = [schritt('vorbereiten', 1), schritt('pruefen', 2)];
expect(laufstand(nurZwei).gruppen.map((g) => g.gruppeId)).toEqual(['vorbereiten', 'pruefen']);
});
it('hängt unbekannte Gruppen hinten an, statt sie zu verschlucken', () => {
const mitFremder = [...beispiel, schritt('nachbereiten', 8)];
const stand = laufstand(mitFremder);
expect(stand.gruppen.at(-1)!.gruppeId).toBe('nachbereiten');
expect(stand.gesamt).toBe(8);
});
it('nennt den nächsten offenen Schritt', () => {
expect(laufstand(beispiel).naechster?.reihenfolge).toBe(4);
});
it('nennt keinen nächsten Schritt mehr, wenn alles erledigt ist', () => {
const fertig = beispiel.map((s) => ({ ...s, erledigt: true }));
const stand = laufstand(fertig);
expect(stand.naechster).toBeNull();
expect(stand.vollstaendig).toBe(true);
expect(stand.prozent).toBe(100);
});
it('kommt mit einem leeren Durchlauf zurecht', () => {
const stand = laufstand([]);
expect(stand.gruppen).toEqual([]);
expect(stand.vollstaendig).toBe(false);
expect(stand.naechster).toBeNull();
});
it('verändert die übergebene Liste nicht', () => {
const kopie = JSON.parse(JSON.stringify(beispiel));
laufstand(beispiel);
expect(beispiel).toEqual(kopie);
});
it('übernimmt eigene Gruppentitel', () => {
const stand = laufstand([schritt('vorbereiten', 1)], { vorbereiten: 'Erst mal alles holen' });
expect(stand.gruppen[0]!.titel).toBe('Erst mal alles holen');
});
});
describe('Abschließen', () => {
it('geht erst, wenn jeder Schritt abgehakt ist', () => {
expect(darfAbschliessen(laufstand(beispiel))).toBe(false);
expect(darfAbschliessen(laufstand(beispiel.map((s) => ({ ...s, erledigt: true }))))).toBe(true);
});
it('geht bei einem leeren Durchlauf nicht', () => {
expect(darfAbschliessen(laufstand([]))).toBe(false);
});
});
describe('Anteil in Prozent', () => {
it('zeigt nie 100, solange etwas offen ist', () => {
expect(anteil(99, 100)).toBe(99);
expect(anteil(100, 100)).toBe(100);
});
it('zeigt nie 0, sobald etwas erledigt ist', () => {
expect(anteil(1, 300)).toBe(1);
});
it('gibt bei einer leeren Liste 0 zurück – anders als beim Wochenfortschritt', () => {
// Eine Woche ohne Inhalt ist "nichts zu tun". Ein Durchlauf ohne Schritte
// ist dagegen kaputt und soll nicht als erledigt erscheinen.
expect(anteil(0, 0)).toBe(0);
});
});
+116
View File
@@ -0,0 +1,116 @@
/**
* Fortschritt eines Checklisten-Durchlaufs, je Gruppe und gesamt.
*
* Reine Rechnung ohne Datenbank – die Aufrufer holen die Schritte und fragen
* hier nach dem Ergebnis.
*/
export type Schrittstand = {
gruppeId: string;
schrittId: string;
text: string;
reihenfolge: number;
erledigt: boolean;
bemerkung: string | null;
};
export type Gruppenstand = {
gruppeId: string;
titel: string;
schritte: Schrittstand[];
erledigt: number;
gesamt: number;
prozent: number;
vollstaendig: boolean;
};
export type Laufstand = {
gruppen: Gruppenstand[];
erledigt: number;
gesamt: number;
prozent: number;
vollstaendig: boolean;
/** Der erste noch offene Schritt – darauf springt die Oberfläche. */
naechster: Schrittstand | null;
};
/** Die vier festen Gruppen einer Checkliste, in genau dieser Reihenfolge. */
export const gruppenReihenfolge = ['vorbereiten', 'durchfuehren', 'pruefen', 'abschliessen'] as const;
export const gruppenTitel: Record<string, string> = {
vorbereiten: 'Vorbereiten',
durchfuehren: 'Durchführen',
pruefen: 'Prüfen',
abschliessen: 'Abschließen',
};
export function laufstand(schritte: Schrittstand[], titelJeGruppe: Record<string, string> = gruppenTitel): Laufstand {
const nachReihenfolge = [...schritte].sort((a, b) => a.reihenfolge - b.reihenfolge);
const gruppen: Gruppenstand[] = [];
for (const gruppeId of gruppenReihenfolge) {
const eigene = nachReihenfolge.filter((s) => s.gruppeId === gruppeId);
if (eigene.length === 0) continue;
const erledigt = eigene.filter((s) => s.erledigt).length;
gruppen.push({
gruppeId,
titel: titelJeGruppe[gruppeId] ?? gruppeId,
schritte: eigene,
erledigt,
gesamt: eigene.length,
prozent: anteil(erledigt, eigene.length),
vollstaendig: erledigt === eigene.length,
});
}
// Gruppen, die nicht zur festen Reihenfolge gehören, hängen hinten an –
// damit geht bei einer erweiterten Vorlage nichts verloren.
const bekannte = new Set<string>(gruppenReihenfolge);
for (const schritt of nachReihenfolge) {
if (bekannte.has(schritt.gruppeId)) continue;
bekannte.add(schritt.gruppeId);
const eigene = nachReihenfolge.filter((s) => s.gruppeId === schritt.gruppeId);
const erledigt = eigene.filter((s) => s.erledigt).length;
gruppen.push({
gruppeId: schritt.gruppeId,
titel: titelJeGruppe[schritt.gruppeId] ?? schritt.gruppeId,
schritte: eigene,
erledigt,
gesamt: eigene.length,
prozent: anteil(erledigt, eigene.length),
vollstaendig: erledigt === eigene.length,
});
}
const erledigt = nachReihenfolge.filter((s) => s.erledigt).length;
const gesamt = nachReihenfolge.length;
return {
gruppen,
erledigt,
gesamt,
prozent: anteil(erledigt, gesamt),
vollstaendig: gesamt > 0 && erledigt === gesamt,
naechster: nachReihenfolge.find((s) => !s.erledigt) ?? null,
};
}
/**
* Anteil in Prozent.
*
* Wie beim Wochenfortschritt: nie 100, solange etwas offen ist, und nie 0,
* sobald etwas erledigt wurde. Ein voller Balken mit offenem Schritt sieht
* nach einem Fehler aus.
*/
export function anteil(erledigt: number, gesamt: number): number {
if (gesamt <= 0) return 0;
if (erledigt <= 0) return 0;
if (erledigt >= gesamt) return 100;
return Math.min(99, Math.max(1, Math.round((erledigt / gesamt) * 100)));
}
/** Darf der Durchlauf abgeschlossen werden? */
export function darfAbschliessen(stand: Laufstand): boolean {
return stand.vollstaendig;
}
@@ -0,0 +1,40 @@
-- Baustein 6: Checklisten-Durchläufe.
--
-- Die Checkliste selbst ist eine Vorlage und liegt als Datei unter
-- content/checklisten/. Hier steht, wer sie wann für welchen Vorgang
-- abgearbeitet hat – das ist der Nachweis, der bleiben muss.
CREATE TABLE checkliste_lauf (
id TEXT PRIMARY KEY,
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
checkliste_id TEXT NOT NULL,
-- Der Titel wird beim Start aus der Vorlage kopiert. Bewusst redundant:
-- Ein abgeschlossener Durchlauf muss auch dann noch lesbar sein, wenn die
-- Vorlage im Repository später umbenannt oder gelöscht wird.
titel TEXT NOT NULL,
datum TEXT NOT NULL,
ticket TEXT,
kunde TEXT,
bemerkung TEXT,
gestartet_am TEXT NOT NULL,
abgeschlossen_am TEXT
);
CREATE INDEX checkliste_lauf_benutzer_idx ON checkliste_lauf (benutzer_id, datum);
CREATE INDEX checkliste_lauf_vorlage_idx ON checkliste_lauf (checkliste_id);
CREATE TABLE checkliste_schritt (
id TEXT PRIMARY KEY,
lauf_id TEXT NOT NULL REFERENCES checkliste_lauf (id) ON DELETE CASCADE,
gruppe_id TEXT NOT NULL,
schritt_id TEXT NOT NULL,
-- Auch der Schritttext wird kopiert, aus demselben Grund wie der Titel.
text TEXT NOT NULL,
reihenfolge INTEGER NOT NULL,
erledigt INTEGER NOT NULL DEFAULT 0 CHECK (erledigt IN (0, 1)),
bemerkung TEXT,
erledigt_am TEXT
);
CREATE UNIQUE INDEX checkliste_schritt_idx ON checkliste_schritt (lauf_id, schritt_id);
CREATE INDEX checkliste_schritt_lauf_idx ON checkliste_schritt (lauf_id, reihenfolge);
+47
View File
@@ -174,6 +174,49 @@ export const anhang = sqliteTable(
(t) => [index('anhang_bezug_idx').on(t.bezugTyp, t.bezugId), index('anhang_benutzer_idx').on(t.benutzerId)],
);
export const checklisteLauf = sqliteTable(
'checkliste_lauf',
{
id: text('id').primaryKey(),
benutzerId: text('benutzer_id')
.notNull()
.references(() => benutzer.id, { onDelete: 'cascade' }),
checklisteId: text('checkliste_id').notNull(),
titel: text('titel').notNull(),
datum: text('datum').notNull(),
ticket: text('ticket'),
kunde: text('kunde'),
bemerkung: text('bemerkung'),
gestartetAm: text('gestartet_am').notNull(),
abgeschlossenAm: text('abgeschlossen_am'),
},
(t) => [
index('checkliste_lauf_benutzer_idx').on(t.benutzerId, t.datum),
index('checkliste_lauf_vorlage_idx').on(t.checklisteId),
],
);
export const checklisteSchritt = sqliteTable(
'checkliste_schritt',
{
id: text('id').primaryKey(),
laufId: text('lauf_id')
.notNull()
.references(() => checklisteLauf.id, { onDelete: 'cascade' }),
gruppeId: text('gruppe_id').notNull(),
schrittId: text('schritt_id').notNull(),
text: text('text').notNull(),
reihenfolge: integer('reihenfolge').notNull(),
erledigt: integer('erledigt').notNull().default(0),
bemerkung: text('bemerkung'),
erledigtAm: text('erledigt_am'),
},
(t) => [
uniqueIndex('checkliste_schritt_idx').on(t.laufId, t.schrittId),
index('checkliste_schritt_lauf_idx').on(t.laufId, t.reihenfolge),
],
);
export type Benutzer = typeof benutzer.$inferSelect;
export type Sitzung = typeof sitzung.$inferSelect;
export type Ausbildung = typeof ausbildung.$inferSelect;
@@ -183,6 +226,8 @@ export type Karte = typeof karte.$inferSelect;
export type KartenVerlauf = typeof kartenVerlauf.$inferSelect;
export type AufgabeStatus = typeof aufgabeStatus.$inferSelect;
export type Anhang = typeof anhang.$inferSelect;
export type ChecklisteLauf = typeof checklisteLauf.$inferSelect;
export type ChecklisteSchritt = typeof checklisteSchritt.$inferSelect;
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
export const alleTabellen = {
@@ -195,4 +240,6 @@ export const alleTabellen = {
kartenVerlauf,
aufgabeStatus,
anhang,
checklisteLauf,
checklisteSchritt,
};
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import { checklistenPdf, type Laufangaben } from './checkliste';
import { fuerPdf } from './grundlage';
import { laufstand, type Schrittstand } from '../checklisten/fortschritt';
/**
* Ein PDF lässt sich nicht sinnvoll Pixel für Pixel prüfen. Was sich prüfen
* lässt und wehtut, wenn es kaputtgeht: Ist es ein gültiges PDF, hat es die
* erwartete Seitenzahl, und stehen keine leeren Seiten darin?
*/
function seitenzahl(pdf: Buffer): number {
// Die Seitenobjekte stehen unkomprimiert im Dokument.
return (pdf.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []).length;
}
function schritt(gruppeId: string, nummer: number, erledigt = true, bemerkung: string | null = null): Schrittstand {
return {
gruppeId,
schrittId: `s${nummer}`,
text: `Schritt ${nummer}: etwas tun`,
reihenfolge: nummer,
erledigt,
bemerkung,
};
}
const angaben: Laufangaben = {
titel: 'Windows 11 installieren und übergeben',
checklisteId: '02-windows-installieren',
bearbeiter: 'Jonas Berger',
datum: '10.09.2026',
ticket: '4711',
kunde: 'Buchhaltung, Platz 12',
bemerkung: 'Gerät lief auffällig langsam – alter Chipsatztreiber.',
gestartetAm: '10.09.2026, 08:15',
abgeschlossenAm: '10.09.2026, 11:40',
worum: 'Von der gesicherten Ausgangslage bis zum unterschriebenen Übergabeprotokoll.',
};
describe('Checklisten-PDF', () => {
it('erzeugt ein gültiges PDF', async () => {
const stand = laufstand([schritt('vorbereiten', 1), schritt('durchfuehren', 2)]);
const pdf = await checklistenPdf(angaben, stand);
expect(pdf.subarray(0, 5).toString('latin1')).toBe('%PDF-');
expect(pdf.byteLength).toBeGreaterThan(1000);
});
it('legt keine leeren Seiten an – die Fußzeile darf keine Seite erzeugen', async () => {
// Der Fehler, den dieser Test verhindert: Die Fußzeile wird unterhalb des
// Satzspiegels gesetzt. Ohne aufgehobenen unteren Rand hält PDFKit das für
// überlaufenden Text und hängt je Fußzeile eine leere Seite an.
const stand = laufstand([schritt('vorbereiten', 1), schritt('durchfuehren', 2)]);
const pdf = await checklistenPdf(angaben, stand);
expect(seitenzahl(pdf)).toBe(1);
});
it('bleibt auch bei vielen Schritten bei einer nachvollziehbaren Seitenzahl', async () => {
const viele = Array.from({ length: 60 }, (_, i) =>
schritt(['vorbereiten', 'durchfuehren', 'pruefen', 'abschliessen'][i % 4]!, i + 1),
);
const pdf = await checklistenPdf(angaben, laufstand(viele));
const seiten = seitenzahl(pdf);
expect(seiten).toBeGreaterThanOrEqual(2);
expect(seiten).toBeLessThanOrEqual(4);
});
it('kommt mit einem leeren Durchlauf zurecht', async () => {
const pdf = await checklistenPdf({ ...angaben, bemerkung: null, ticket: null, kunde: null }, laufstand([]));
expect(seitenzahl(pdf)).toBe(1);
});
it('verkraftet sehr lange Schritttexte und Bemerkungen', async () => {
const lang = laufstand([
{
gruppeId: 'durchfuehren',
schrittId: 's1',
text: 'Sehr langer Schritt. '.repeat(40),
reihenfolge: 1,
erledigt: true,
bemerkung: 'Eine ebenfalls sehr lange Bemerkung. '.repeat(30),
},
]);
const pdf = await checklistenPdf(angaben, lang);
expect(seitenzahl(pdf)).toBeGreaterThanOrEqual(1);
expect(pdf.byteLength).toBeGreaterThan(1000);
});
});
describe('Zeichenvorrat für das PDF', () => {
it('lässt deutsche Umlaute und ß unangetastet', () => {
expect(fuerPdf('Übergabeprotokoll für Größe, Maß und Fußzeile – Straße')).toBe(
'Übergabeprotokoll für Größe, Maß und Fußzeile – Straße',
);
});
it('lässt Euro, Paragraf und Grad stehen', () => {
expect(fuerPdf('120 € nach § 5, bei 50 °C')).toBe('120 € nach § 5, bei 50 °C');
});
it('ersetzt typografische Anführungszeichen durch gerade', () => {
expect(fuerPdf('„so" und »so« und ‚so’')).toBe('"so" und "so" und \'so\'');
});
it('ersetzt Zeichen, die die Standardschriften nicht kennen', () => {
expect(fuerPdf('Haken ✓ Kreis ○ Dreieck ▲ Punkt •')).toBe('Haken x Kreis - Dreieck - Punkt -');
});
it('macht aus Auslassungspunkten drei Punkte', () => {
expect(fuerPdf('und so weiter …')).toBe('und so weiter ...');
});
it('führt geschützte Leerzeichen auf gewöhnliche zurück', () => {
expect(fuerPdf('5 MB')).toBe('5 MB');
});
it('wirft alles weg, was gar nicht darstellbar wäre', () => {
expect(fuerPdf('Text mit Emoji 🙂 und Pfeil →')).toBe('Text mit Emoji und Pfeil ->');
});
it('behält Zeilenumbrüche', () => {
expect(fuerPdf('Zeile eins\nZeile zwei')).toBe('Zeile eins\nZeile zwei');
});
});
+124
View File
@@ -0,0 +1,124 @@
import {
abschnitt,
alsPuffer,
angabe,
FARBEN,
fuerPdf,
fusszeilen,
INHALTSBREITE,
kaestchen,
kopf,
neuesDokument,
platzPruefen,
SEITE,
unterschriften,
type Dokument,
} from './grundlage';
import type { Laufstand } from '../checklisten/fortschritt';
/**
* Ein abgeschlossener Checklisten-Durchlauf als PDF.
*
* Zweck ist der Nachweis: Wer hat wann welche Schritte abgearbeitet, mit
* welchen Bemerkungen. Das Blatt soll ausgedruckt und abgeheftet werden können.
*/
export type Laufangaben = {
titel: string;
checklisteId: string;
bearbeiter: string;
datum: string;
ticket: string | null;
kunde: string | null;
bemerkung: string | null;
gestartetAm: string;
abgeschlossenAm: string | null;
worum?: string;
};
export async function checklistenPdf(angaben: Laufangaben, stand: Laufstand): Promise<Buffer> {
const dokument = neuesDokument(`Checkliste ${angaben.titel}`, angaben.bearbeiter);
kopf(dokument, {
titel: fuerPdf(angaben.titel),
untertitel: angaben.worum ? fuerPdf(angaben.worum) : undefined,
rechts: fuerPdf(angaben.bearbeiter),
});
// --- Kopfangaben ---
angabe(dokument, 'Datum', fuerPdf(angaben.datum));
angabe(dokument, 'Bearbeiter', fuerPdf(angaben.bearbeiter));
if (angaben.ticket) angabe(dokument, 'Ticket', fuerPdf(angaben.ticket));
if (angaben.kunde) angabe(dokument, 'Kunde / Bereich', fuerPdf(angaben.kunde));
angabe(dokument, 'Begonnen', fuerPdf(angaben.gestartetAm));
angabe(
dokument,
'Abgeschlossen',
angaben.abgeschlossenAm ? fuerPdf(angaben.abgeschlossenAm) : 'noch offen',
);
angabe(dokument, 'Stand', `${stand.erledigt} von ${stand.gesamt} Schritten erledigt`);
// --- Gruppen mit Schritten ---
for (const gruppe of stand.gruppen) {
abschnitt(dokument, fuerPdf(gruppe.titel), `${gruppe.erledigt} von ${gruppe.gesamt}`);
for (const schritt of gruppe.schritte) {
zeichneSchritt(dokument, schritt.text, schritt.erledigt, schritt.bemerkung);
}
}
// --- Bemerkung zum Durchlauf ---
if (angaben.bemerkung) {
abschnitt(dokument, 'Bemerkung zum Durchlauf');
platzPruefen(dokument, 40);
dokument
.font('Helvetica')
.fontSize(10)
.fillColor(FARBEN.text)
.text(fuerPdf(angaben.bemerkung), SEITE.rand, dokument.y, { width: INHALTSBREITE, align: 'left' });
dokument.x = SEITE.rand;
}
unterschriften(dokument, ['Datum, Unterschrift Bearbeiter', 'Datum, Unterschrift Ausbilder']);
fusszeilen(
dokument,
fuerPdf(`Lern-Bar - Checkliste ${angaben.checklisteId} - ${angaben.titel} - ${angaben.datum}`),
);
return alsPuffer(dokument);
}
function zeichneSchritt(dokument: Dokument, text: string, erledigt: boolean, bemerkung: string | null): void {
const textBreite = INHALTSBREITE - 20;
const sauber = fuerPdf(text);
dokument.font('Helvetica').fontSize(10);
const hoehe = dokument.heightOfString(sauber, { width: textBreite });
const bemerkungHoehe = bemerkung
? dokument.font('Helvetica-Oblique').fontSize(9).heightOfString(fuerPdf(bemerkung), { width: textBreite - 10 }) + 3
: 0;
platzPruefen(dokument, hoehe + bemerkungHoehe + 8);
const y = dokument.y;
kaestchen(dokument, SEITE.rand + 1, y + 1.5, erledigt);
dokument
.font('Helvetica')
.fontSize(10)
.fillColor(erledigt ? FARBEN.text : FARBEN.leise)
.text(sauber, SEITE.rand + 20, y, { width: textBreite });
if (bemerkung) {
dokument
.font('Helvetica-Oblique')
.fontSize(9)
.fillColor(FARBEN.leise)
.text(fuerPdf(bemerkung), SEITE.rand + 30, dokument.y + 1, { width: textBreite - 10 });
}
dokument.x = SEITE.rand;
dokument.y += 5;
dokument.fillColor(FARBEN.text);
}
+265
View File
@@ -0,0 +1,265 @@
import PDFDocument from 'pdfkit';
/**
* Gemeinsame Grundlage für alle PDF-Ausgaben.
*
* Zur Schriftwahl: Verwendet werden die in jedem PDF-Betrachter vorhandenen
* Standardschriften (Helvetica). Das spart eine eingebettete Schriftdatei und
* hält die Ausgabe klein. Der Preis: Diese Schriften können nur den
* WinAnsi-Zeichenvorrat. Deutsche Umlaute, ß, €, § und ° sind darin enthalten –
* Zeichen wie ✓, ○ oder ▲ **nicht**. Häkchen und Kästchen werden deshalb
* gezeichnet, nicht gesetzt. Siehe `kaestchen()`.
*/
export const FARBEN = {
petrol: '#1F4E5F',
bernstein: '#D98A2B',
text: '#16242A',
leise: '#4D646D',
linie: '#C8D8DE',
flaeche: '#EDF3F5',
gut: '#1D6F4A',
} as const;
export const SEITE = {
breite: 595.28, // A4 in Punkt
hoehe: 841.89,
rand: 45,
} as const;
export const INHALTSBREITE = SEITE.breite - 2 * SEITE.rand;
export type Dokument = PDFKit.PDFDocument;
export type Kopfangaben = {
titel: string;
untertitel?: string;
/** Rechts oben, z. B. der Name des Azubis. */
rechts?: string;
};
/** Legt ein neues Dokument an. */
export function neuesDokument(titel: string, verfasser: string): Dokument {
return new PDFDocument({
size: 'A4',
margins: { top: SEITE.rand, bottom: SEITE.rand + 22, left: SEITE.rand, right: SEITE.rand },
info: {
Title: titel,
Author: verfasser,
Creator: 'Lern-Bar',
Producer: 'Lern-Bar',
CreationDate: new Date(),
},
autoFirstPage: true,
bufferPages: true,
});
}
/** Sammelt das fertige Dokument in einen Puffer. */
export function alsPuffer(dokument: Dokument): Promise<Buffer> {
return new Promise((erfuellen, ablehnen) => {
const teile: Buffer[] = [];
dokument.on('data', (stueck: Buffer) => teile.push(stueck));
dokument.on('end', () => erfuellen(Buffer.concat(teile)));
dokument.on('error', ablehnen);
dokument.end();
});
}
/** Kopfzeile mit Titel, Untertitel und farbigem Balken. */
export function kopf(dokument: Dokument, angaben: Kopfangaben): void {
const oben = SEITE.rand;
dokument
.font('Helvetica-Bold')
.fontSize(17)
.fillColor(FARBEN.petrol)
.text(angaben.titel, SEITE.rand, oben, { width: INHALTSBREITE - 150 });
if (angaben.rechts) {
dokument
.font('Helvetica')
.fontSize(10)
.fillColor(FARBEN.leise)
.text(angaben.rechts, SEITE.breite - SEITE.rand - 150, oben + 4, { width: 150, align: 'right' });
}
dokument.x = SEITE.rand;
if (angaben.untertitel) {
dokument
.font('Helvetica')
.fontSize(10.5)
.fillColor(FARBEN.leise)
.text(angaben.untertitel, SEITE.rand, dokument.y + 2, { width: INHALTSBREITE });
}
const linieY = dokument.y + 8;
dokument
.moveTo(SEITE.rand, linieY)
.lineTo(SEITE.breite - SEITE.rand, linieY)
.lineWidth(2)
.strokeColor(FARBEN.bernstein)
.stroke();
dokument.y = linieY + 14;
dokument.x = SEITE.rand;
dokument.fillColor(FARBEN.text);
}
/** Zwischenüberschrift. */
export function abschnitt(dokument: Dokument, titel: string, zusatz?: string): void {
platzPruefen(dokument, 40);
dokument.moveDown(0.6);
const y = dokument.y;
dokument.font('Helvetica-Bold').fontSize(11.5).fillColor(FARBEN.petrol).text(titel, SEITE.rand, y);
if (zusatz) {
dokument
.font('Helvetica')
.fontSize(9.5)
.fillColor(FARBEN.leise)
.text(zusatz, SEITE.breite - SEITE.rand - 180, y + 1, { width: 180, align: 'right' });
}
dokument.x = SEITE.rand;
dokument.y = Math.max(dokument.y, y + 15);
dokument
.moveTo(SEITE.rand, dokument.y)
.lineTo(SEITE.breite - SEITE.rand, dokument.y)
.lineWidth(0.75)
.strokeColor(FARBEN.linie)
.stroke();
dokument.y += 8;
dokument.fillColor(FARBEN.text);
}
/** Eine Zeile aus Beschriftung und Wert. */
export function angabe(dokument: Dokument, beschriftung: string, wert: string, breite = INHALTSBREITE): void {
platzPruefen(dokument, 18);
const y = dokument.y;
dokument.font('Helvetica').fontSize(9).fillColor(FARBEN.leise).text(beschriftung, SEITE.rand, y, { width: 110 });
dokument
.font('Helvetica')
.fontSize(10.5)
.fillColor(FARBEN.text)
.text(wert || '–', SEITE.rand + 115, y - 1, { width: breite - 115 });
dokument.x = SEITE.rand;
dokument.y = Math.max(dokument.y, y + 14);
}
/**
* Ein Ankreuzkästchen mit Häkchen.
*
* Gezeichnet statt gesetzt, weil die Standardschriften kein Häkchen kennen –
* und weil ein gezeichnetes Kästchen in jedem Betrachter gleich aussieht.
*/
export function kaestchen(dokument: Dokument, x: number, y: number, angehakt: boolean, groesse = 9): void {
dokument
.roundedRect(x, y, groesse, groesse, 1.5)
.lineWidth(0.8)
.strokeColor(angehakt ? FARBEN.gut : FARBEN.linie)
.stroke();
if (angehakt) {
dokument
.moveTo(x + groesse * 0.22, y + groesse * 0.52)
.lineTo(x + groesse * 0.42, y + groesse * 0.74)
.lineTo(x + groesse * 0.8, y + groesse * 0.24)
.lineWidth(1.4)
.strokeColor(FARBEN.gut)
.stroke();
}
}
/** Unterschriftenzeilen nebeneinander. */
export function unterschriften(dokument: Dokument, beschriftungen: string[]): void {
platzPruefen(dokument, 70);
dokument.moveDown(1.6);
const spalte = (INHALTSBREITE - (beschriftungen.length - 1) * 20) / beschriftungen.length;
const y = dokument.y + 24;
beschriftungen.forEach((beschriftung, stelle) => {
const x = SEITE.rand + stelle * (spalte + 20);
dokument.moveTo(x, y).lineTo(x + spalte, y).lineWidth(0.75).strokeColor(FARBEN.leise).stroke();
dokument.font('Helvetica').fontSize(8.5).fillColor(FARBEN.leise).text(beschriftung, x, y + 4, { width: spalte });
});
dokument.x = SEITE.rand;
dokument.y = y + 20;
dokument.fillColor(FARBEN.text);
}
/** Sorgt dafür, dass noch genug Platz auf der Seite ist – sonst neue Seite. */
export function platzPruefen(dokument: Dokument, benoetigt: number): void {
const unterkante = SEITE.hoehe - SEITE.rand - 30;
if (dokument.y + benoetigt > unterkante) {
dokument.addPage();
dokument.x = SEITE.rand;
}
}
/**
* Fußzeile auf jeder Seite: Herkunft links, Seitenzahl rechts.
* Wird ganz am Ende aufgerufen, wenn die Seitenzahl feststeht.
*/
export function fusszeilen(dokument: Dokument, links: string): void {
const bereich = dokument.bufferedPageRange();
for (let n = 0; n < bereich.count; n += 1) {
dokument.switchToPage(bereich.start + n);
// Die Fußzeile steht unterhalb des Satzspiegels. Ohne diesen Kniff hält
// PDFKit das für überlaufenden Text und legt je Fußzeile eine neue – leere –
// Seite an. Der untere Rand wird deshalb kurz aufgehoben.
const untererRand = dokument.page.margins.bottom;
dokument.page.margins.bottom = 0;
const y = SEITE.hoehe - SEITE.rand - 12;
dokument
.moveTo(SEITE.rand, y - 6)
.lineTo(SEITE.breite - SEITE.rand, y - 6)
.lineWidth(0.5)
.strokeColor(FARBEN.linie)
.stroke();
dokument.font('Helvetica').fontSize(8).fillColor(FARBEN.leise);
dokument.text(links, SEITE.rand, y, { width: INHALTSBREITE - 80, lineBreak: false });
dokument.text(`Seite ${n + 1} von ${bereich.count}`, SEITE.breite - SEITE.rand - 80, y, {
width: 80,
align: 'right',
lineBreak: false,
});
dokument.page.margins.bottom = untererRand;
}
}
/**
* Ersetzt Zeichen, die die Standardschriften nicht kennen.
*
* Der Zeichenvorrat ist WinAnsi. Was darin fehlt, würde sonst als leeres
* Rechteck oder gar nicht erscheinen – am ehesten trifft das typografische
* Feinheiten aus den Inhaltsdateien.
*/
export function fuerPdf(text: string): string {
return (
text
.replace(/[\u2018\u2019\u201a\u2039\u203a]/g, "'")
.replace(/[\u201c\u201d\u201e\u00ab\u00bb]/g, '"')
.replace(/[\u2010\u2011\u2012\u2015]/g, '-')
.replace(/\u2026/g, '...')
.replace(/[\u2713\u2714]/g, 'x')
.replace(/[\u25cb\u25cf\u25a0\u25a1\u25b2\u25ba\u2022]/g, '-')
// Geschützte und schmale Leerzeichen auf ein gewöhnliches zurückführen.
.replace(/[\u00a0\u2007\u2009\u202f]/g, ' ')
.replace(/[\u2190-\u21ff]/g, '->')
// Was jetzt noch übrig ist und nicht in WinAnsi steht, fliegt raus.
// WinAnsi = Latin-1 plus Gedankenstriche und Eurozeichen.
.replace(/[^\u0020-\u00ff\u2013\u2014\u20ac\n\t]/g, '')
);
}
+5 -1
View File
@@ -4,6 +4,7 @@ import { gelesenSet } from '../repos/fortschritt';
import { planVon } from '../repos/wochenplanung';
import { uebersicht as kartenUebersicht } from '../repos/karten';
import { anzahlAbgenommen } from '../repos/aufgaben';
import { anzahlAbgeschlossen } from '../repos/checklisten';
import { heute } from '../zeit';
import { aktuelleWoche } from './wochen';
import { wochenFortschritt, type Fortschritt } from './fortschritt';
@@ -80,7 +81,10 @@ export function wochensicht(ausbildungId: string, azubiId: string, wochenNummer:
aufgaben: { erledigt: anzahlAbgenommen(azubiId, aufgaben.map((a) => a.id)), gesamt: aufgaben.length },
// Diese Bereiche werden mit ihren Bausteinen scharf geschaltet. Bis dahin
// zeigen sie ehrlich an, was die Woche vorsieht und noch offen ist.
checklisten: { erledigt: 0, gesamt: checklisten.length },
checklisten: {
erledigt: anzahlAbgeschlossen(azubiId, checklisten.map((c) => c.id)),
gesamt: checklisten.length,
},
kontrolle: { erledigt: 0, gesamt: kontrolle ? 1 : 0 },
});
+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;
}