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:
co-authored by
Claude Opus 5
parent
ffbca01bdc
commit
b9d6041f3b
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user