- 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>
381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
'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('.', ',');
|
||
}
|