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
@@ -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;
}