Files
lernbar/src/app/(angemeldet)/berichtsheft/Blattformular.tsx
T
Florian WildeandClaude Opus 5 7c4a051347 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>
2026-09-10 23:09:54 +02:00

381 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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('.', ',');
}