Baustein 7: Berichtsheft mit Vorschlaegen, Pruefung, Abzeichnen und PDF

- Taegliche Stichworte, je Tag ein eigenes Formular
- Wochenblatt mit Betrieb, Berufsschule und Sonstiges, je mit Stunden
- Vorschlaege aus Tagesnotizen, Aufgaben, Checklisten und Handouts
- Pruefung mitlaufend beim Tippen: fehlende Stunden, Floskeln,
  IP- und MAC-Adressen, E-Mail, Zugangsdaten, Kundennamen
- Warnung, keine Sperre - Einreichen ist immer moeglich
- Formulierungshilfe mit Taetigkeitsverben und Gegenueberstellungen
- Einreichen, zurueckgeben mit Anmerkung, abzeichnen; danach gesperrt
- Fehlende Wochen werden benannt und lassen sich nachtragen; die laufende
  Nummer richtet sich nach dem Zeitraum, nicht nach dem Anlegen
- PDF im IHK-Layout, einzeln oder als ganzer Zeitraum

Behoben: Im PDF-Kopffeld liefen die Spalten auseinander (dokument.y als
Bezugspunkt in der Schleife). Das Abzeichnen-Formular hatte keine action -
gefunden ueber die ESLint-Meldung zur ungenutzten Serveraktion.

55 neue Tests, insgesamt 241.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 23:09:54 +02:00
co-authored by Claude Opus 5
parent b9d6041f3b
commit 7c4a051347
47 changed files with 3679 additions and 8 deletions
@@ -0,0 +1,296 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import { alsWochenblatt, notizenIm } from '@/lib/repos/berichtsheft';
import { staendeVon as aufgabenStaende } from '@/lib/repos/aufgaben';
import { laeufeVon } from '@/lib/repos/checklisten';
import { gelesenSet } from '@/lib/repos/fortschritt';
import { planVon } from '@/lib/repos/wochenplanung';
import { baueVorschlaege, type Rohdaten } from '@/lib/berichtsheft/vorschlaege';
import { pruefeWochenblatt, summeBetrieb, summeSchule, summeStunden } from '@/lib/berichtsheft/pruefung';
import { alsDatumText, alsZeitpunktText, kalenderwocheText } from '@/lib/zeit';
import { Blattformular } from '../Blattformular';
import { Formulierungshilfe } from '../Formulierungshilfe';
import { BerichtsMarke } from '../BerichtsMarke';
import { Abzeichnen } from './Abzeichnen';
import { blattZurueckziehen, blattZumLesen } from '../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ berichtId: string }> }): Promise<Metadata> {
const { berichtId } = await params;
const gelesen = await blattZumLesen(berichtId);
return { title: gelesen ? `Wochenblatt Nr. ${gelesen.bericht.nr}` : 'Wochenblatt' };
}
export default async function BlattSeite({ params }: { params: Promise<{ berichtId: string }> }) {
const { berichtId } = await params;
// Rechteprüfung steckt hier drin: kein Zugriff bedeutet null bedeutet 404.
const gelesen = await blattZumLesen(berichtId);
if (!gelesen) notFound();
const { benutzer, bericht } = gelesen;
const eigenes = bericht.benutzerId === benutzer.id;
const azubi = eigenes ? benutzer : benutzerNachId(bericht.benutzerId);
if (!azubi) notFound();
const blatt = alsWochenblatt(bericht);
const warnungen = pruefeWochenblatt(blatt);
const vorschlaege = eigenes ? sammleVorschlaege(azubi.id, bericht.von, bericht.bis) : [];
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/berichtsheft">Berichtsheft</Link>
{!eigenes ? ` › ${azubi.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="blatt-nr blatt-nr-gross">Nr. {bericht.nr}</span>
<BerichtsMarke status={bericht.status} />
<span className="leise">{kalenderwocheText(bericht.von)}</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
</h1>
<p className="leise">
{azubi.name}
{bericht.eingereichtAm ? ` · eingereicht ${alsZeitpunktText(bericht.eingereichtAm)}` : ''}
</p>
</header>
{bericht.status === 'abgezeichnet' ? (
<p className="hinweis hinweis-gut">
✓ Abgezeichnet am {alsZeitpunktText(bericht.abgezeichnetAm)}
{bericht.abgezeichnetVon ? ` von ${benutzerNachId(bericht.abgezeichnetVon)?.name ?? 'dem Ausbilder'}` : ''}.
Das Blatt ist gesperrt.
</p>
) : null}
{bericht.status === 'zurueckgegeben' && bericht.anmerkung ? (
<div className="hinweis hinweis-warnung">
<strong>Zur Nacharbeit zurückgegeben:</strong>
<p className="notiz-anzeige" style={{ marginTop: '0.4rem' }}>
{bericht.anmerkung}
</p>
</div>
) : null}
{bericht.status === 'abgezeichnet' && bericht.anmerkung ? (
<div className="karte">
<strong>Anmerkung des Ausbilders</strong>
<p className="notiz-anzeige" style={{ marginTop: '0.4rem' }}>
{bericht.anmerkung}
</p>
</div>
) : null}
{eigenes ? (
<Blattformular
berichtId={bericht.id}
anfangsblatt={blatt}
vorschlaege={vorschlaege}
status={bericht.status}
/>
) : (
<AnsichtFuerAusbilder blatt={blatt} warnungen={warnungen} />
)}
{/* --- Werkzeuge --- */}
<div className="reihe nicht-drucken">
<a href={`/berichtsheft/${bericht.id}/pdf`} className="knopf knopf-zweit" target="_blank" rel="noreferrer">
Dieses Blatt als PDF
</a>
{eigenes && bericht.status === 'eingereicht' ? (
<form action={blattZurueckziehen}>
<input type="hidden" name="berichtId" value={bericht.id} />
<button type="submit" className="knopf knopf-still">
Einreichung zurückziehen
</button>
</form>
) : null}
</div>
{!eigenes ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="abzeichnen">
<h2 id="abzeichnen" style={{ marginBottom: 0 }}>
Abzeichnen
</h2>
<Abzeichnen berichtId={bericht.id} anmerkung={bericht.anmerkung ?? ''} status={bericht.status} />
</section>
) : null}
{eigenes ? <Formulierungshilfe /> : null}
</article>
);
}
/** Die Nur-Lese-Ansicht für den Ausbilder – dieselben Daten, ohne Formular. */
function AnsichtFuerAusbilder({
blatt,
warnungen,
}: {
blatt: ReturnType<typeof alsWochenblatt>;
warnungen: ReturnType<typeof pruefeWochenblatt>;
}) {
return (
<div className="stapel">
<section className="karte stapel-eng" aria-labelledby="betrieb-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="betrieb-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Betriebliche Tätigkeiten und Unterweisungen
</h2>
<span className="leise">{zahlText(summeBetrieb(blatt))} h</span>
</div>
{blatt.betrieb.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Keine Eintragung.
</p>
) : (
<ul className="leseliste">
{blatt.betrieb.map((eintrag, stelle) => (
<li key={stelle}>
<span>{eintrag.text}</span>
<span className="leise">{eintrag.stunden > 0 ? `${zahlText(eintrag.stunden)} h` : '–'}</span>
</li>
))}
</ul>
)}
</section>
<section className="karte stapel-eng" aria-labelledby="schule-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="schule-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Berufsschule
</h2>
<span className="leise">{zahlText(summeSchule(blatt))} h</span>
</div>
{blatt.schule.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Keine Eintragung.
</p>
) : (
<ul className="leseliste">
{blatt.schule.map((eintrag, stelle) => (
<li key={stelle}>
<span>
{eintrag.lernfeld ? <strong>{eintrag.lernfeld} </strong> : null}
{eintrag.themen}
</span>
<span className="leise">{eintrag.stunden > 0 ? `${zahlText(eintrag.stunden)} h` : '–'}</span>
</li>
))}
</ul>
)}
</section>
{blatt.sonstiges ? (
<section className="karte stapel-eng" aria-labelledby="sonstiges-lesen">
<h2 id="sonstiges-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Sonstiges
</h2>
<p className="notiz-anzeige">{blatt.sonstiges}</p>
</section>
) : null}
<section className="karte stapel-eng" aria-labelledby="pruefung-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="pruefung-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Prüfung
</h2>
<strong>{zahlText(summeStunden(blatt))} Stunden gesamt</strong>
</div>
{warnungen.length === 0 ? (
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
✓ Nichts zu beanstanden.
</p>
) : (
<ul className="warnliste">
{warnungen.map((warnung, stelle) => (
<li key={stelle} data-schwere={warnung.schwere}>
<span className="warnzeichen" aria-hidden="true">
{warnung.schwere === 'warnung' ? '!' : 'i'}
</span>
<span>
{warnung.text}
{warnung.auszug ? <em> &ndash; &bdquo;{warnung.auszug}&ldquo;</em> : null}
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}
/** Stellt zusammen, was die Anwendung über diese Woche weiß. */
function sammleVorschlaege(azubiId: string, von: string, bis: string) {
const alle = inhalte();
const notizen = notizenIm(azubiId, von, bis).map((n) => ({
datum: n.datum,
text: n.text,
stunden: n.stunden,
}));
// Praxisaufgaben, die in dieser Woche abgegeben oder abgenommen wurden.
const staende = aufgabenStaende(azubiId);
const aufgaben = [...staende.values()]
.filter((s) => {
const zeitpunkt = (s.abgenommenAm ?? s.abgegebenAm ?? '').slice(0, 10);
return zeitpunkt >= von && zeitpunkt <= bis;
})
.map((s) => {
const aufgabe = alle.aufgabeNach.get(s.aufgabeId);
return {
id: s.aufgabeId,
titel: aufgabe?.titel ?? s.aufgabeId,
zeitrahmen: aufgabe?.zeitrahmen ?? '',
status: s.status,
};
});
const checklisten = laeufeVon(azubiId)
.filter((l) => l.abgeschlossenAm !== null && l.datum >= von && l.datum <= bis)
.map((l) => ({ id: l.id, titel: l.titel, ticket: l.ticket, kunde: l.kunde }));
// Handouts der Wochen, die in diesem Zeitraum lagen – nur die gelesenen.
const ausbildung = ausbildungNachAzubi(azubiId);
const gelesen = gelesenSet(azubiId);
const wochenImZeitraum = ausbildung
? planVon(ausbildung.id).filter((z) => z.geplantVon <= bis && z.geplantBis >= von)
: [];
const handouts = wochenImZeitraum
.flatMap((z) => alle.wocheNach.get(z.wochenNummer)?.handouts ?? [])
.filter((id) => gelesen.has(id))
.map((id) => ({ id, titel: alle.handoutNach.get(id)?.titel ?? id }));
const lernfelder = [
...new Set(
wochenImZeitraum
.flatMap((z) => alle.wocheNach.get(z.wochenNummer)?.handouts ?? [])
.map((id) => alle.handoutNach.get(id)?.lernfeld)
.filter((lf): lf is string => Boolean(lf)),
),
].map((id) => ({ id, titel: alle.lernfeldNach.get(id)?.titel ?? id }));
const rohdaten: Rohdaten = { tagesnotizen: notizen, aufgaben, checklisten, handouts, lernfelder };
return baueVorschlaege(rohdaten);
}
function zahlText(wert: number): string {
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
}