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,380 @@
'use client';
import { useActionState, useState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { blattEinreichen, blattSpeichern, type Rueckmeldung } from './aktionen';
import { pruefeWochenblatt, summeStunden, type Wochenblatt } from '@/lib/berichtsheft/pruefung';
import type { Vorschlag } from '@/lib/berichtsheft/vorschlaege';
/**
* Das Wochenblatt zum Ausfüllen.
*
* Die Prüfung läuft im Browser mit, während getippt wird – dieselbe Funktion,
* die auch serverseitig verwendet wird. Sie warnt, sie sperrt nicht: Der Knopf
* zum Einreichen bleibt immer bedienbar.
*/
export function Blattformular({
berichtId,
anfangsblatt,
vorschlaege,
status,
}: {
berichtId: string;
anfangsblatt: Wochenblatt;
vorschlaege: Vorschlag[];
status: string;
}) {
const [blatt, setzeBlatt] = useState<Wochenblatt>(() => ({
betrieb: anfangsblatt.betrieb.length > 0 ? anfangsblatt.betrieb : [{ text: '', stunden: 0 }],
schule: anfangsblatt.schule.length > 0 ? anfangsblatt.schule : [{ lernfeld: '', themen: '', stunden: 0 }],
sonstiges: anfangsblatt.sonstiges,
}));
const [uebernommen, setzeUebernommen] = useState<string[]>([]);
const [speicherZustand, speichern] = useActionState<Rueckmeldung, FormData>(blattSpeichern, {});
const [einreichZustand, einreichen] = useActionState<Rueckmeldung, FormData>(blattEinreichen, {});
const gesperrt = status === 'abgezeichnet';
const warnungen = pruefeWochenblatt(blatt);
const ernste = warnungen.filter((w) => w.schwere === 'warnung');
function betriebAendern(stelle: number, teil: Partial<{ text: string; stunden: number }>) {
setzeBlatt((v) => ({
...v,
betrieb: v.betrieb.map((e, i) => (i === stelle ? { ...e, ...teil } : e)),
}));
}
function schuleAendern(stelle: number, teil: Partial<{ lernfeld: string; themen: string; stunden: number }>) {
setzeBlatt((v) => ({
...v,
schule: v.schule.map((e, i) => (i === stelle ? { ...e, ...teil } : e)),
}));
}
function uebernehmen(vorschlag: Vorschlag) {
setzeUebernommen((v) => [...v, vorschlag.id]);
if (vorschlag.ziel === 'schule') {
setzeBlatt((v) => {
const leer = v.schule.findIndex((e) => e.lernfeld === '' && e.themen === '');
const neu = { lernfeld: vorschlag.herkunft, themen: vorschlag.text, stunden: vorschlag.stunden };
return {
...v,
schule: leer >= 0 ? v.schule.map((e, i) => (i === leer ? neu : e)) : [...v.schule, neu],
};
});
return;
}
setzeBlatt((v) => {
const leer = v.betrieb.findIndex((e) => e.text.trim() === '');
const neu = { text: vorschlag.text, stunden: vorschlag.stunden };
return {
...v,
betrieb: leer >= 0 ? v.betrieb.map((e, i) => (i === leer ? neu : e)) : [...v.betrieb, neu],
};
});
}
const offeneVorschlaege = vorschlaege.filter((v) => !uebernommen.includes(v.id));
return (
<div className="stapel">
{!gesperrt && offeneVorschlaege.length > 0 ? (
<section className="karte stapel-eng nicht-drucken" aria-labelledby="vorschlaege">
<div>
<h2 id="vorschlaege" style={{ marginBottom: '0.15rem', fontSize: '1rem' }}>
Vorschläge aus dieser Woche
</h2>
<p className="leise" style={{ margin: 0 }}>
Übernehmen und dann <strong>in eigene Worte bringen</strong>. Ein wörtlich übernommener Satz ist kein
Ausbildungsnachweis, und die Stundenangabe ist nur ein Anhaltspunkt.
</p>
</div>
<ul className="vorschlagsliste">
{offeneVorschlaege.map((vorschlag) => (
<li key={vorschlag.id}>
<button type="button" className="vorschlag" onClick={() => uebernehmen(vorschlag)}>
<span className="vorschlag-quelle" data-quelle={vorschlag.quelle}>
{beschriftung[vorschlag.quelle]}
</span>
<span className="vorschlag-text">{vorschlag.text}</span>
<span className="leise">
{vorschlag.herkunft}
{vorschlag.stunden > 0 ? ` · ${zahlText(vorschlag.stunden)} h` : ''}
</span>
<span className="vorschlag-plus" aria-hidden="true">
+
</span>
</button>
</li>
))}
</ul>
</section>
) : null}
<form action={speichern} className="stapel" id="blattformular">
<input type="hidden" name="berichtId" value={berichtId} />
{/* --- Betriebliche Tätigkeiten --- */}
<section className="stapel-eng" aria-labelledby="betrieb">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="betrieb" style={{ margin: 0 }}>
Betriebliche Tätigkeiten und Unterweisungen
</h2>
<span className="leise">{zahlText(summeStunden({ ...blatt, schule: [] }))} h</span>
</div>
{blatt.betrieb.map((eintrag, stelle) => (
<div key={stelle} className="blattzeile">
<div className="blattzeile-text">
<label htmlFor={`betrieb-${stelle}`} className="nur-lesehilfe">
Tätigkeit {stelle + 1}
</label>
<textarea
id={`betrieb-${stelle}`}
name="betriebText"
rows={2}
maxLength={600}
value={eintrag.text}
disabled={gesperrt}
onChange={(e) => betriebAendern(stelle, { text: e.target.value })}
placeholder="Was hast du gemacht? Gerät oder Dienst nennen, Tätigkeit, Ergebnis."
aria-invalid={warnungen.some((w) => w.feld === `betrieb.${stelle}` && w.schwere === 'warnung')}
/>
<Feldwarnungen warnungen={warnungen} feld={`betrieb.${stelle}`} />
</div>
<div className="blattzeile-stunden">
<label htmlFor={`betrieb-std-${stelle}`}>Std.</label>
<input
id={`betrieb-std-${stelle}`}
name="betriebStunden"
type="number"
min={0}
max={60}
step={0.25}
value={eintrag.stunden || ''}
disabled={gesperrt}
onChange={(e) => betriebAendern(stelle, { stunden: Number(e.target.value) || 0 })}
/>
</div>
{!gesperrt ? (
<button
type="button"
className="knopf knopf-still zeile-weg"
onClick={() => setzeBlatt((v) => ({ ...v, betrieb: v.betrieb.filter((_, i) => i !== stelle) }))}
title="Zeile entfernen"
>
<span aria-hidden="true">×</span>
<span className="nur-lesehilfe">Tätigkeit {stelle + 1} entfernen</span>
</button>
) : null}
</div>
))}
{!gesperrt ? (
<div>
<button
type="button"
className="knopf knopf-zweit"
onClick={() => setzeBlatt((v) => ({ ...v, betrieb: [...v.betrieb, { text: '', stunden: 0 }] }))}
>
Zeile hinzufügen
</button>
</div>
) : null}
</section>
{/* --- Berufsschule --- */}
<section className="stapel-eng" aria-labelledby="schule">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="schule" style={{ margin: 0 }}>
Berufsschule
</h2>
<span className="leise">{zahlText(summeStunden({ ...blatt, betrieb: [] }))} h</span>
</div>
{blatt.schule.map((eintrag, stelle) => (
<div key={stelle} className="blattzeile">
<div className="blattzeile-lernfeld">
<label htmlFor={`lf-${stelle}`}>Lernfeld</label>
<input
id={`lf-${stelle}`}
name="schuleLernfeld"
type="text"
maxLength={10}
value={eintrag.lernfeld}
disabled={gesperrt}
onChange={(e) => schuleAendern(stelle, { lernfeld: e.target.value })}
placeholder="LF3"
/>
</div>
<div className="blattzeile-text">
<label htmlFor={`themen-${stelle}`} className="nur-lesehilfe">
Themen
</label>
<textarea
id={`themen-${stelle}`}
name="schuleThemen"
rows={2}
maxLength={600}
value={eintrag.themen}
disabled={gesperrt}
onChange={(e) => schuleAendern(stelle, { themen: e.target.value })}
placeholder="Themen des Unterrichts"
/>
<Feldwarnungen warnungen={warnungen} feld={`schule.${stelle}`} />
</div>
<div className="blattzeile-stunden">
<label htmlFor={`schule-std-${stelle}`}>Std.</label>
<input
id={`schule-std-${stelle}`}
name="schuleStunden"
type="number"
min={0}
max={60}
step={0.25}
value={eintrag.stunden || ''}
disabled={gesperrt}
onChange={(e) => schuleAendern(stelle, { stunden: Number(e.target.value) || 0 })}
/>
</div>
{!gesperrt ? (
<button
type="button"
className="knopf knopf-still zeile-weg"
onClick={() => setzeBlatt((v) => ({ ...v, schule: v.schule.filter((_, i) => i !== stelle) }))}
title="Zeile entfernen"
>
<span aria-hidden="true">×</span>
<span className="nur-lesehilfe">Schulzeile {stelle + 1} entfernen</span>
</button>
) : null}
</div>
))}
{!gesperrt ? (
<div>
<button
type="button"
className="knopf knopf-zweit"
onClick={() =>
setzeBlatt((v) => ({ ...v, schule: [...v.schule, { lernfeld: '', themen: '', stunden: 0 }] }))
}
>
Zeile hinzufügen
</button>
</div>
) : null}
</section>
{/* --- Sonstiges --- */}
<section className="stapel-eng" aria-labelledby="sonstiges">
<h2 id="sonstiges" style={{ margin: 0 }}>
Sonstiges
</h2>
<label htmlFor="sonstiges-feld" className="nur-lesehilfe">
Sonstiges
</label>
<textarea
id="sonstiges-feld"
name="sonstiges"
rows={3}
maxLength={2000}
value={blatt.sonstiges}
disabled={gesperrt}
onChange={(e) => setzeBlatt((v) => ({ ...v, sonstiges: e.target.value }))}
placeholder="Urlaub, Krankheit, Feiertage, Betriebsversammlung – alles, was keine Tätigkeit ist."
/>
<Feldwarnungen warnungen={warnungen} feld="sonstiges" />
</section>
{/* --- Summe und Prüfung --- */}
<section className="karte stapel-eng" aria-labelledby="pruefung">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="pruefung" 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>
)}
<p className="leise" style={{ marginBottom: 0 }}>
Das sind Hinweise, keine Sperre. Du kannst jederzeit einreichen.
</p>
</section>
<Meldung zustand={speicherZustand} />
{!gesperrt ? (
<div className="reihe nicht-drucken">
<Absenden className="knopf knopf-zweit">Speichern</Absenden>
<button type="submit" className="knopf knopf-akzent" formAction={einreichen}>
{ernste.length > 0 ? `Trotz ${ernste.length} Warnungen einreichen` : 'Einreichen'}
</button>
</div>
) : null}
<Meldung zustand={einreichZustand} />
</form>
</div>
);
}
function Feldwarnungen({
warnungen,
feld,
}: {
warnungen: ReturnType<typeof pruefeWochenblatt>;
feld: string;
}) {
const eigene = warnungen.filter((w) => w.feld === feld);
if (eigene.length === 0) return null;
return (
<ul className="feldwarnung">
{eigene.map((warnung, stelle) => (
<li key={stelle} data-schwere={warnung.schwere}>
{warnung.text}
</li>
))}
</ul>
);
}
const beschriftung: Record<string, string> = {
tagesnotiz: 'Tagesnotiz',
aufgabe: 'Aufgabe',
checkliste: 'Checkliste',
handout: 'Lerninhalt',
};
function zahlText(wert: number): string {
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
}