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,197 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { erfordereBenutzer, erfordereBewertungsrecht, ausbildungVonAzubi } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import {
abzeichnen,
berichtNachId,
einreichen,
inhaltSpeichern,
notizSetzen,
sorgeFuerBlatt,
zurueckgeben,
} from '@/lib/repos/berichtsheft';
import type { Wochenblatt } from '@/lib/berichtsheft/pruefung';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/** Zum Bearbeiten: nur das eigene Blatt, und nur solange es nicht abgezeichnet ist. */
async function blattZumBearbeiten(berichtId: string) {
const benutzer = await erfordereBenutzer();
const bericht = berichtNachId(berichtId);
if (!bericht) throw new Error('Dieses Wochenblatt gibt es nicht.');
if (bericht.benutzerId !== benutzer.id) throw new Error('Kein Zugriff auf dieses Wochenblatt.');
return { benutzer, bericht };
}
/** Zum Lesen: das eigene Blatt oder das eines betreuten Azubis. */
export async function blattZumLesen(berichtId: string) {
const benutzer = await erfordereBenutzer();
const bericht = berichtNachId(berichtId);
if (!bericht) return null;
const bezug = ausbildungVonAzubi(bericht.benutzerId);
if (!darfAzubiDatenSehen(benutzer, bericht.benutzerId, bezug)) return null;
return { benutzer, bericht };
}
function aktualisiere(berichtId?: string) {
revalidatePath('/berichtsheft');
if (berichtId) revalidatePath(`/berichtsheft/${berichtId}`);
revalidatePath('/');
}
// --- Tagesnotizen ----------------------------------------------------------
export async function tagesnotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return { fehler: 'Tagesnotizen schreibt der Azubi.' };
const datum = String(formular.get('datum') ?? '').trim();
if (!datumMuster.test(datum)) return { fehler: 'Ungültiges Datum.' };
const text = String(formular.get('text') ?? '');
if (text.length > 2000) return { fehler: 'Die Notiz ist zu lang – höchstens 2000 Zeichen.' };
const stunden = zahl(formular.get('stunden'));
if (stunden > 24) return { fehler: 'Mehr als 24 Stunden an einem Tag sind nicht plausibel.' };
notizSetzen(benutzer.id, datum, text, stunden);
aktualisiere();
return { erfolg: text.trim() ? 'Notiz gespeichert.' : 'Notiz entfernt.' };
}
// --- Wochenblatt -----------------------------------------------------------
export async function blattAnlegen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return;
const datum = String(formular.get('datum') ?? '').trim();
if (!datumMuster.test(datum)) return;
const bericht = sorgeFuerBlatt(benutzer.id, datum);
aktualisiere(bericht.id);
redirect(`/berichtsheft/${bericht.id}`);
}
/** Liest das Blatt aus dem Formular. Leere Zeilen fliegen raus. */
function leseBlatt(formular: FormData): Wochenblatt {
const betriebTexte = formular.getAll('betriebText').map(String);
const betriebStunden = formular.getAll('betriebStunden');
const schuleLernfelder = formular.getAll('schuleLernfeld').map(String);
const schuleThemen = formular.getAll('schuleThemen').map(String);
const schuleStunden = formular.getAll('schuleStunden');
return {
betrieb: betriebTexte
.map((text, stelle) => ({ text: text.trim(), stunden: zahl(betriebStunden[stelle]) }))
.filter((e) => e.text !== '' || e.stunden > 0),
schule: schuleLernfelder
.map((lernfeld, stelle) => ({
lernfeld: lernfeld.trim(),
themen: (schuleThemen[stelle] ?? '').trim(),
stunden: zahl(schuleStunden[stelle]),
}))
.filter((e) => e.lernfeld !== '' || e.themen !== '' || e.stunden > 0),
sonstiges: String(formular.get('sonstiges') ?? '').trim(),
};
}
export async function blattSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId);
if (bericht.status === 'abgezeichnet') {
return { fehler: 'Das Blatt ist abgezeichnet und damit gesperrt.' };
}
const blatt = leseBlatt(formular);
if (blatt.betrieb.length > 30) return { fehler: 'Mehr als 30 Tätigkeiten in einer Woche sind zu viel.' };
if (blatt.sonstiges.length > 2000) return { fehler: 'Der Text unter „Sonstiges" ist zu lang.' };
inhaltSpeichern(berichtId, blatt);
aktualisiere(berichtId);
return { erfolg: 'Gespeichert.' };
}
/**
* Einreichen.
*
* Die Prüfung ist eine **Warnung, keine Sperre** – deshalb wird hier nichts
* geprüft, was das Einreichen verhindern könnte. Der Azubi hat die Warnungen
* auf der Seite gesehen; ob er sie beherzigt, entscheidet er.
*/
export async function blattEinreichen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId);
if (bericht.status === 'abgezeichnet') return { fehler: 'Das Blatt ist schon abgezeichnet.' };
if (bericht.status === 'eingereicht') return { fehler: 'Das Blatt liegt schon beim Ausbilder.' };
// Vor dem Einreichen den letzten Stand übernehmen, damit nichts verlorengeht.
inhaltSpeichern(berichtId, leseBlatt(formular));
einreichen(berichtId);
aktualisiere(berichtId);
return { erfolg: 'Eingereicht. Dein Ausbilder sieht das Blatt jetzt unter „Was auf dich wartet".' };
}
export async function blattZurueckziehen(formular: FormData): Promise<void> {
const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId);
if (bericht.status !== 'eingereicht') return;
zurueckgeben(berichtId, bericht.anmerkung ?? '');
aktualisiere(berichtId);
}
// --- Abzeichnen durch den Ausbilder ----------------------------------------
export async function blattAbzeichnen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer();
const berichtId = String(formular.get('berichtId') ?? '');
const bericht = berichtNachId(berichtId);
if (!bericht) return { fehler: 'Dieses Wochenblatt gibt es nicht.' };
erfordereBewertungsrecht(ausbilder, bericht.benutzerId);
if (bericht.status === 'abgezeichnet') return { fehler: 'Das Blatt ist schon abgezeichnet.' };
const anmerkung = String(formular.get('anmerkung') ?? '').trim();
if (anmerkung.length > 2000) return { fehler: 'Die Anmerkung ist zu lang.' };
abzeichnen(berichtId, ausbilder.id, anmerkung || null);
aktualisiere(berichtId);
return { erfolg: 'Abgezeichnet. Das Blatt ist damit gesperrt.' };
}
export async function blattZurueckgeben(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer();
const berichtId = String(formular.get('berichtId') ?? '');
const bericht = berichtNachId(berichtId);
if (!bericht) return { fehler: 'Dieses Wochenblatt gibt es nicht.' };
erfordereBewertungsrecht(ausbilder, bericht.benutzerId);
const anmerkung = String(formular.get('anmerkung') ?? '').trim();
if (!anmerkung) {
return { fehler: 'Ohne Anmerkung weiß der Azubi nicht, was er ändern soll. Schreib bitte einen Satz dazu.' };
}
if (anmerkung.length > 2000) return { fehler: 'Die Anmerkung ist zu lang.' };
zurueckgeben(berichtId, anmerkung);
aktualisiere(berichtId);
return { erfolg: 'Zurückgegeben. Der Azubi kann das Blatt jetzt wieder bearbeiten.' };
}
function zahl(wert: FormDataEntryValue | null | undefined): number {
const roh = String(wert ?? '').replace(',', '.').trim();
const gelesen = Number(roh);
return Number.isFinite(gelesen) && gelesen > 0 ? Math.round(gelesen * 100) / 100 : 0;
}