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,77 @@
import { erfordereBenutzer, ausbildungVonAzubi } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { alsWochenblatt, berichteVon } from '@/lib/repos/berichtsheft';
import { berichtsheftPdf, type Blattangaben } from '@/lib/pdf/berichtsheft';
import { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
export const dynamic = 'force-dynamic';
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/**
* Ein ganzer Zeitraum als PDF, ein Blatt je Seite.
*
* /berichtsheft/pdf?von=2026-08-01&bis=2026-12-31[&azubi=…]
*
* Ohne `azubi` das eigene Berichtsheft. Der Ausbilder gibt die Kennung an –
* geprüft wird sie serverseitig gegen das Ausbildungsverhältnis.
*/
export async function GET(anfrage: Request) {
const benutzer = await erfordereBenutzer();
const adresse = new URL(anfrage.url);
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (adresse.searchParams.get('azubi') ?? '');
if (!azubiId) return new Response('Nicht gefunden.', { status: 404 });
const bezug = ausbildungVonAzubi(azubiId);
if (!darfAzubiDatenSehen(benutzer, azubiId, bezug)) {
return new Response('Nicht gefunden.', { status: 404 });
}
const von = adresse.searchParams.get('von') ?? '';
const bis = adresse.searchParams.get('bis') ?? '';
if (!datumMuster.test(von) || !datumMuster.test(bis) || von > bis) {
return new Response('Ungültiger Zeitraum.', { status: 400 });
}
const azubi = benutzerNachId(azubiId);
const ausbilder = bezug ? benutzerNachId(bezug.ausbilderId) : null;
// Aufsteigend, damit das Heft in der richtigen Reihenfolge ausgedruckt wird.
const berichte = berichteVon(azubiId)
.filter((b) => b.bis >= von && b.von <= bis)
.sort((a, b) => a.nr - b.nr);
const blaetter: Blattangaben[] = berichte.map((bericht) => {
const abzeichner = bericht.abgezeichnetVon ? benutzerNachId(bericht.abgezeichnetVon) : null;
return {
nr: bericht.nr,
von: alsDatumText(bericht.von),
bis: alsDatumText(bericht.bis),
azubi: azubi?.name ?? 'unbekannt',
ausbilder: ausbilder?.name ?? '',
beruf: bezug?.beruf ?? 'Fachinformatiker/-in für Systemintegration',
ausbildungsjahr: bezug ? ausbildungsjahr(bezug.beginn, bericht.von) : null,
status: bericht.status,
anmerkung: bericht.anmerkung,
abgezeichnetVon: abzeichner?.name ?? null,
abgezeichnetAm: bericht.abgezeichnetAm ? alsZeitpunktText(bericht.abgezeichnetAm) : null,
blatt: alsWochenblatt(bericht),
};
});
const pdf = await berichtsheftPdf(blaetter, `${alsDatumText(von)} bis ${alsDatumText(bis)}`);
const dateiname = `Ausbildungsnachweis_${von}_bis_${bis}.pdf`;
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',
},
});
}