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' },
];