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:
co-authored by
Claude Opus 5
parent
b9d6041f3b
commit
7c4a051347
@@ -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> – „{warnung.auszug}“</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('.', ',');
|
||||
}
|
||||
Reference in New Issue
Block a user