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
+53
View File
@@ -26,6 +26,58 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
## [Unveröffentlicht]
## [0.7.0] – 2026-09-10
Das Berichtsheft – der Teil, ohne den es keine Zulassung zur Abschlussprüfung
gibt.
### Hinzugefügt
- **Tägliche Stichworte**: je Werktag ein eigenes kleines Formular mit Text und
Stundenzahl. Bewusst je Tag getrennt absendbar, damit es am Handy in zwei
Minuten geht und nicht die ganze Woche neu geschrieben wird
- **Wochenblatt** mit Berichtszeitraum, betrieblichen Tätigkeiten und
Unterweisungen (je mit Stundenangabe), Berufsschule (Lernfeld, Themen,
Stunden) und Sonstiges; Zeilen lassen sich hinzufügen und entfernen
- **Vorschläge aus dem System**: aus den Tagesnotizen der Woche, den in dieser
Woche abgegebenen oder abgenommenen Praxisaufgaben, den abgeschlossenen
Checklisten-Durchläufen und den gelesenen Handouts. Ein Klick übernimmt den
Vorschlag ins Blatt, wo er bearbeitet wird
- **Prüfung vor dem Absenden**, mitlaufend beim Tippen:
- fehlende oder unglaubwürdige Stundenangaben, Wochensumme
- Floskeln wie „diverse Arbeiten" oder „wie in der Vorwoche" – aber nur, wenn
die Floskel den Eintrag ausmacht oder kein Tätigkeitsverb vorkommt
- IP- und MAC-Adressen, E-Mail-Adressen, Zugangsdaten, Telefonnummern und
Kundennamen (erkannt an der Rechtsform)
- **Warnung, keine Sperre.** Der Knopf zum Einreichen bleibt immer bedienbar
- **Formulierungshilfe**: Tätigkeitsverben nach Einsatzfeld gruppiert, sechs
Regeln und acht Gegenüberstellungen „so besser nicht / so ist es richtig"
samt Begründung
- **Abzeichnen**: Der Ausbilder sieht eingereichte Blätter unter „Was auf dich
wartet", liest sie in einer Nur-Lese-Ansicht mit derselben Prüfung und kann
abzeichnen oder zur Nacharbeit zurückgeben. Zurückgeben verlangt eine
Anmerkung – ohne sie weiß der Azubi nicht, was zu ändern ist
- Abgezeichnete Blätter sind **gesperrt**: keine Bearbeitung, keine Vorschläge,
kein Einreichen mehr
- **Fehlende Wochen** werden benannt: Für jede vergangene Woche ohne Blatt gibt
es einen Knopf zum Nachtragen. Die laufende Nummer richtet sich nach dem
Zeitraum, nicht nach dem Anlegen – wer nachträgt, bekommt die richtige Nummer
- **PDF im Layout eines üblichen IHK-Ausbildungsnachweises**: Kopffeld mit Name,
Beruf, Ausbildungsjahr und Berichtszeitraum, gerahmte Kästen mit
Stundenspalte und Zwischensummen, Anmerkung des Ausbilders,
Unterschriftenzeilen für Auszubildenden und Ausbilder. Einzeln oder als
ganzer Zeitraum mit einem Blatt je Seite
- Das Ausbildungsjahr wird aus dem Ausbildungsbeginn gerechnet
- 55 Tests für Prüfung, Vorschläge und PDF-Erzeugung
### Behoben
- Im Kopffeld des PDF liefen die beiden Spalten auseinander: `dokument.y`
wandert bei jedem Schreibvorgang weiter und taugte nicht als Bezugspunkt
innerhalb der Schleife
- Das Abzeichnen-Formular hatte keine `action` – der Knopf hätte nichts getan.
Aufgefallen ist das nur, weil ESLint die ungenutzte Serveraktion gemeldet hat
## [0.6.0] – 2026-09-10
Checklisten mit Durchläufen – und der erste PDF-Export.
@@ -267,6 +319,7 @@ anmelden und verwalten.
- `npm run konto:anlegen` für das erste Ausbilderkonto
[Unveröffentlicht]: #unveröffentlicht
[0.7.0]: #070--2026-09-10
[0.6.0]: #060--2026-09-10
[0.5.0]: #050--2026-09-10
[0.4.0]: #040--2026-09-10
+40
View File
@@ -7,6 +7,46 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
---
## 2026-09-10 · Baustein 7 – Berichtsheft
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 21:50 | 22:05 | 0:15 | Prüflogik: Stunden, Floskeln, schützenswerte Angaben – mit 31 Tests |
| 22:05 | 22:15 | 0:10 | Vorschläge aus dem System, Formulierungshilfe, 15 Tests |
| 22:15 | 22:25 | 0:10 | Datenmodell, Migration, Repository |
| 22:25 | 22:40 | 0:15 | PDF im IHK-Layout mit Kästen, Stundenspalten und Unterschriften |
| 22:40 | 23:00 | 0:20 | Seiten, Formulare, Abzeichnen, Tagesnotizen |
| 23:00 | 23:15 | 0:15 | Durchstich über den ganzen Weg, zwei Fehler behoben |
**Summe Baustein 7: rund 1 h 25 min**
### Was aufgefallen ist
- Die Floskelprüfung war der kniffligste Teil. „Diverse Arbeiten" ist eine
Floskel, „Diverse Patchkabel konfektioniert und beschriftet" nicht. Gelöst
über zwei Bedingungen: Die Prüfung schlägt an, wenn die Floskel den Eintrag
im Wesentlichen ausmacht **oder** wenn im ganzen Eintrag kein einziges
Tätigkeitsverb vorkommt.
- Bei den Kundennamen hat ein Test einen echten Fehler gefunden: „Beispiel
e. V." wurde nicht erkannt. Ursache war ein `\b` am Ende des Musters – nach
einem Punkt gibt es vor einem Leerzeichen keine Wortgrenze. Ersetzt durch
einen negativen Vorausblick.
- ESLint hat wieder einen echten Bug gefunden: Das Abzeichnen-Formular hatte
gar keine `action`, der Knopf hätte nichts bewirkt. Sichtbar wurde das nur
über die Meldung „Serveraktion zugewiesen, aber nie benutzt".
- Im PDF-Kopffeld liefen die beiden Spalten auseinander, weil ich `dokument.y`
als Bezugspunkt in der Schleife benutzt habe – der Wert wandert aber bei
jedem Schreibvorgang weiter. Wieder etwas, das erst beim Ansehen auffiel.
- Beim Prüfen des PDF-Zugriffs ohne Anmeldung kam zunächst 200. Erst der
zweite Blick mit `maxRedirects: 0` zeigte: 307 auf /anmelden, 0 Byte, kein
PDF. Playwright war der Weiterleitung gefolgt. Merke: Bei
Berechtigungsprüfungen nie den Weiterleitungen folgen lassen.
- Die laufende Nummer eines Wochenblatts richtet sich nach dem Zeitraum, nicht
nach der Reihenfolge des Anlegens. Wer eine Woche nachträgt, soll Nr. 3
bekommen und nicht Nr. 12 – die dahinterliegenden Blätter rücken auf.
---
## 2026-09-10 · Baustein 6 – Checklisten und PDF
| Von | Bis | Dauer | Tätigkeit |
+52
View File
@@ -257,3 +257,55 @@ Beim Checklisten-Durchlauf fallen die beiden Rechte auseinander:
Der Ausbilder hakt also nichts für seinen Azubi ab. Im Nachweis soll keine
Arbeit stehen, die der Azubi nicht selbst gemacht hat. Für die Abnahme gibt es
die Praxisaufgaben – dort ist es genau andersherum.
## E27 · Die Berichtsheftprüfung warnt, sie sperrt nicht
`pruefeWochenblatt()` liefert eine Liste von Warnungen und nichts anderes. Es
gibt bewusst **kein** `darfEinreichen()`.
Grund: Eine Anwendung, die das Einreichen verhindert, erzieht dazu, Unsinn
einzutragen, bis die Prüfung zufrieden ist. Der Azubi soll die Warnung sehen –
was er damit macht, entscheidet er. Der Knopf heißt dann eben
„Trotz 3 Warnungen einreichen".
Die Prüfung läuft im Browser beim Tippen mit und benutzt dieselbe Funktion wie
der Server. Sie ist keine Sicherheitsmaßnahme, sondern eine Hilfe.
## E28 · Floskeln werden am Zusammenhang erkannt, nicht am Wort
„Diverse Arbeiten" ist eine Floskel. „Diverse Patchkabel konfektioniert,
gemessen und im Verteiler beschriftet" ist keine. Ein reiner Wortabgleich würde
beide gleich behandeln und wäre nach dem dritten Fehlalarm nur noch lästig.
Die Prüfung schlägt deshalb nur an, wenn
- die Floskel den Eintrag im Wesentlichen ausmacht (höchstens 20 Zeichen
Rest), **oder**
- im ganzen Eintrag kein einziges Tätigkeitsverb vorkommt.
Dieselbe Verbliste trägt auch die Formulierungshilfe.
## E29 · Die laufende Nummer richtet sich nach dem Zeitraum
Wird eine vergessene Woche nachgetragen, bekommt sie die Nummer, die ihr
zeitlich zusteht; alle späteren Blätter rücken eine Nummer weiter.
Der Grund ist das Papierformular: Ein Ausbildungsnachweis wird chronologisch
abgeheftet, und Nr. 12 zwischen Nr. 3 und Nr. 4 wäre gegenüber der IHK nicht
erklärbar.
## E30 · Kundennamen werden an der Rechtsform erkannt
Personennamen lassen sich nicht zuverlässig erkennen, ohne ständig Fehlalarme
zu erzeugen. Firmennamen dagegen schon: Sie enden fast immer auf GmbH, AG, KG,
mbH, e. V. und Ähnliches.
Das Muster schließt bewusst mit einem negativen Vorausblick statt mit `\b` –
Rechtsformen wie „e. V." enden auf einen Punkt, und dort gibt es vor einem
Leerzeichen keine Wortgrenze. Genau dieser Fall war zuerst durchgerutscht.
## E31 · Die Formulierungshilfe steht im Quelltext, nicht unter content/
Tätigkeitsverben und die Gegenüberstellungen sind Bedienhilfe der Anwendung,
kein Ausbildungsinhalt. Sie ändern sich nicht mit dem Lehrgang und gehören
deshalb nicht in dieselbe Ablage wie Handouts und Aufgaben.
+3 -3
View File
@@ -9,9 +9,9 @@ und – der wichtigste Teil – den gesetzlich vorgeschriebenen Ausbildungsnachw
Gebaut für **einen Ausbilder und ein bis drei Auszubildende**. Kein
Lernplattform-Baukasten, keine Mandanten, keine Cloud.
> **Stand:** Bausteine 1 bis 6 von 10 sind fertig – Fundament, Inhalte,
> Wochenplan, Merkkarten, Praxisaufgaben, Checklisten. Der
> [Fahrplan](ROADMAP.md) zeigt, was folgt, das
> **Stand:** Bausteine 1 bis 7 von 10 sind fertig – Fundament, Inhalte,
> Wochenplan, Merkkarten, Praxisaufgaben, Checklisten und das Berichtsheft.
> Der [Fahrplan](ROADMAP.md) zeigt, was folgt, das
> [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
---
+9 -1
View File
@@ -72,7 +72,15 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
- [x] Abgeschlossene Durchläufe bleiben als Nachweis und sind gesperrt
- [x] PDF-Export mit Unterschriftenzeilen
- [x] Ausbilder liest mit, hakt aber nicht für den Azubi ab
- [ ] **B7 · Berichtsheft: Tagesnotizen, Vorschläge, Prüfung, Abzeichnen, PDF**
- [x] **B7 · Berichtsheft: Tagesnotizen, Vorschläge, Prüfung, Abzeichnen, PDF**
- [x] Tägliche Stichworte, je Tag ein eigenes kleines Formular
- [x] Vorschläge aus Tagesnotizen, Aufgaben, Checklisten und Handouts
- [x] Formulierungshilfe: Tätigkeitsverben und „so besser nicht / so ist es richtig"
- [x] Prüfung auf fehlende Stunden, Floskeln und schützenswerte Angaben
- [x] Warnung, keine Sperre – Einreichen ist immer möglich
- [x] Einreichen, zurückgeben mit Anmerkung, abzeichnen; danach gesperrt
- [x] Fehlende Wochen werden benannt und lassen sich nachtragen
- [x] PDF im IHK-Layout, einzeln oder als ganzer Zeitraum
## 0.5 – Bewertung
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+11
View File
@@ -122,6 +122,17 @@ async function main() {
await seite.waitForSelector('h1');
await bild(seite, '18-checklisten');
await seite.goto(`${BASIS}/berichtsheft`);
await seite.waitForSelector('h1');
await bild(seite, '21-berichtsheft');
const blatt = await seite.$('.blattzeile-link');
if (blatt) {
await blatt.click();
await seite.waitForSelector('h1');
await bild(seite, '22-wochenblatt');
}
const lauf = await seite.$('.laufzeile-haupt');
if (lauf) {
await lauf.click();
@@ -0,0 +1,18 @@
import type { BerichtStatus } from '@/lib/db/schema';
/** Zustand eines Wochenblatts – nie nur über die Farbe, immer mit Zeichen. */
export const berichtsStatusText: Record<BerichtStatus, { text: string; ton: string; zeichen: string }> = {
entwurf: { text: 'Entwurf', ton: 'neutral', zeichen: '○' },
eingereicht: { text: 'eingereicht', ton: 'warnung', zeichen: '▲' },
zurueckgegeben: { text: 'zurückgegeben', ton: 'fehler', zeichen: '↩' },
abgezeichnet: { text: 'abgezeichnet', ton: 'gut', zeichen: '✓' },
};
export function BerichtsMarke({ status }: { status: BerichtStatus }) {
const marke = berichtsStatusText[status];
return (
<span className="marke-status" data-ton={marke.ton}>
{marke.zeichen} {marke.text}
</span>
);
}
@@ -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('.', ',');
}
@@ -0,0 +1,74 @@
import { gegenueberstellungen, regeln, verbgruppen } from '@/lib/berichtsheft/formulierung';
/**
* Formulierungshilfe: Tätigkeitsverben und die Gegenüberstellung
* „so besser nicht / so ist es richtig".
*
* Eingeklappt, weil sie beim zweiten Wochenblatt nur noch stört – aber sie
* bleibt griffbereit, statt in einer Anleitung zu verschwinden.
*/
export function Formulierungshilfe() {
return (
<details className="karte formulierungshilfe nicht-drucken">
<summary>
<strong>Formulierungshilfe</strong>
<span className="leise"> – Tätigkeitsverben und Beispiele</span>
</summary>
<div className="stapel" style={{ marginTop: '0.9rem' }}>
<section aria-labelledby="regeln">
<h3 id="regeln" style={{ fontSize: '0.95rem' }}>
Sechs Regeln
</h3>
<ol className="stapel-eng" style={{ margin: 0, paddingLeft: '1.2rem' }}>
{regeln.map((regel, stelle) => (
<li key={stelle}>{regel}</li>
))}
</ol>
</section>
<section aria-labelledby="verben">
<h3 id="verben" style={{ fontSize: '0.95rem' }}>
Tätigkeitsverben
</h3>
<div className="verbgruppen">
{verbgruppen.map((gruppe) => (
<div key={gruppe.titel}>
<span className="wochentag-marke">{gruppe.titel}</span>
<p style={{ margin: 0 }}>{gruppe.verben.join(' · ')}</p>
</div>
))}
</div>
</section>
<section aria-labelledby="beispiele">
<h3 id="beispiele" style={{ fontSize: '0.95rem' }}>
So besser nicht – so ist es richtig
</h3>
<ul className="gegenueberstellung">
{gegenueberstellungen.map((beispiel, stelle) => (
<li key={stelle}>
<p className="schlecht">
<span className="marke-status" data-ton="fehler">
✗ so nicht
</span>{' '}
{beispiel.schlecht}
</p>
<p className="gut">
<span className="marke-status" data-ton="gut">
✓ besser
</span>{' '}
{beispiel.gut}
</p>
<p className="leise" style={{ margin: 0 }}>
{beispiel.warum}
</p>
</li>
))}
</ul>
</section>
</div>
</details>
);
}
@@ -0,0 +1,80 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { tagesnotizSpeichern, type Rueckmeldung } from './aktionen';
export type Tagesfeld = {
datum: string;
wochentag: string;
datumText: string;
text: string;
stunden: number;
istHeute: boolean;
};
/**
* Die täglichen Stichworte.
*
* Der Zweck: zwei Minuten am Tag statt einer Stunde Rekonstruktion am Freitag.
* Deshalb je Tag ein eigenes kleines Formular, das für sich allein absendet –
* ein großes Formular für die ganze Woche würde bei jedem Speichern alles
* neu schreiben und wäre am Handy unbrauchbar.
*/
export function Tagesnotizen({ tage }: { tage: Tagesfeld[] }) {
return (
<div className="tagesnotizen">
{tage.map((tag) => (
<Tagesnotiz key={tag.datum} tag={tag} />
))}
</div>
);
}
function Tagesnotiz({ tag }: { tag: Tagesfeld }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(tagesnotizSpeichern, {});
return (
<form action={aktion} className={`tagesnotiz${tag.istHeute ? ' tagesnotiz-heute' : ''}`}>
<input type="hidden" name="datum" value={tag.datum} />
<div className="tagesnotiz-kopf">
<strong>{tag.wochentag}</strong>
<span className="leise">{tag.datumText}</span>
{tag.istHeute ? (
<span className="marke-status" data-ton="warnung">
● heute
</span>
) : null}
</div>
<label htmlFor={`notiz-${tag.datum}`} className="nur-lesehilfe">
Stichworte für {tag.wochentag}
</label>
<textarea
id={`notiz-${tag.datum}`}
name="text"
rows={2}
maxLength={2000}
defaultValue={tag.text}
placeholder="Stichworte genügen – woran hast du heute gearbeitet?"
/>
<div className="tagesnotiz-fuss">
<label htmlFor={`std-${tag.datum}`}>Stunden</label>
<input
id={`std-${tag.datum}`}
name="stunden"
type="number"
min={0}
max={24}
step={0.25}
defaultValue={tag.stunden || ''}
/>
<Absenden className="knopf knopf-still">Merken</Absenden>
</div>
<Meldung zustand={zustand} />
</form>
);
}
@@ -0,0 +1,56 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { blattAbzeichnen, blattZurueckgeben, type Rueckmeldung } from '../aktionen';
/**
* Abzeichnen oder zurückgeben.
*
* Zurückgeben verlangt eine Anmerkung – ohne sie weiß der Azubi nicht, was er
* ändern soll, und das Blatt pendelt hin und her.
*/
export function Abzeichnen({
berichtId,
anmerkung,
status,
}: {
berichtId: string;
anmerkung: string;
status: string;
}) {
const [abzeichenZustand, abzeichnen] = useActionState<Rueckmeldung, FormData>(blattAbzeichnen, {});
const [rueckgabeZustand, zurueckgeben] = useActionState<Rueckmeldung, FormData>(blattZurueckgeben, {});
if (status === 'abgezeichnet') return null;
return (
<form action={abzeichnen} className="stapel-eng">
<input type="hidden" name="berichtId" value={berichtId} />
<Meldung zustand={abzeichenZustand} />
<Meldung zustand={rueckgabeZustand} />
<label htmlFor="anmerkung">Anmerkung an den Azubi</label>
<textarea
id="anmerkung"
name="anmerkung"
rows={3}
maxLength={2000}
defaultValue={anmerkung}
placeholder="Beim Abzeichnen freiwillig, beim Zurückgeben nötig: Was soll er ändern?"
/>
<div className="reihe">
<Absenden className="knopf knopf-akzent">Abzeichnen</Absenden>
<button type="submit" className="knopf knopf-zweit" formAction={zurueckgeben}>
Zur Nacharbeit zurückgeben
</button>
</div>
<p className="leise" style={{ marginBottom: 0 }}>
Abzeichnen sperrt das Blatt endgültig – der Azubi kann es danach nicht mehr ändern.
</p>
</form>
);
}
@@ -0,0 +1,296 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import { alsWochenblatt, notizenIm } from '@/lib/repos/berichtsheft';
import { staendeVon as aufgabenStaende } from '@/lib/repos/aufgaben';
import { laeufeVon } from '@/lib/repos/checklisten';
import { gelesenSet } from '@/lib/repos/fortschritt';
import { planVon } from '@/lib/repos/wochenplanung';
import { baueVorschlaege, type Rohdaten } from '@/lib/berichtsheft/vorschlaege';
import { pruefeWochenblatt, summeBetrieb, summeSchule, summeStunden } from '@/lib/berichtsheft/pruefung';
import { alsDatumText, alsZeitpunktText, kalenderwocheText } from '@/lib/zeit';
import { Blattformular } from '../Blattformular';
import { Formulierungshilfe } from '../Formulierungshilfe';
import { BerichtsMarke } from '../BerichtsMarke';
import { Abzeichnen } from './Abzeichnen';
import { blattZurueckziehen, blattZumLesen } from '../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ berichtId: string }> }): Promise<Metadata> {
const { berichtId } = await params;
const gelesen = await blattZumLesen(berichtId);
return { title: gelesen ? `Wochenblatt Nr. ${gelesen.bericht.nr}` : 'Wochenblatt' };
}
export default async function BlattSeite({ params }: { params: Promise<{ berichtId: string }> }) {
const { berichtId } = await params;
// Rechteprüfung steckt hier drin: kein Zugriff bedeutet null bedeutet 404.
const gelesen = await blattZumLesen(berichtId);
if (!gelesen) notFound();
const { benutzer, bericht } = gelesen;
const eigenes = bericht.benutzerId === benutzer.id;
const azubi = eigenes ? benutzer : benutzerNachId(bericht.benutzerId);
if (!azubi) notFound();
const blatt = alsWochenblatt(bericht);
const warnungen = pruefeWochenblatt(blatt);
const vorschlaege = eigenes ? sammleVorschlaege(azubi.id, bericht.von, bericht.bis) : [];
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/berichtsheft">Berichtsheft</Link>
{!eigenes ? ` › ${azubi.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="blatt-nr blatt-nr-gross">Nr. {bericht.nr}</span>
<BerichtsMarke status={bericht.status} />
<span className="leise">{kalenderwocheText(bericht.von)}</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
</h1>
<p className="leise">
{azubi.name}
{bericht.eingereichtAm ? ` · eingereicht ${alsZeitpunktText(bericht.eingereichtAm)}` : ''}
</p>
</header>
{bericht.status === 'abgezeichnet' ? (
<p className="hinweis hinweis-gut">
✓ Abgezeichnet am {alsZeitpunktText(bericht.abgezeichnetAm)}
{bericht.abgezeichnetVon ? ` von ${benutzerNachId(bericht.abgezeichnetVon)?.name ?? 'dem Ausbilder'}` : ''}.
Das Blatt ist gesperrt.
</p>
) : null}
{bericht.status === 'zurueckgegeben' && bericht.anmerkung ? (
<div className="hinweis hinweis-warnung">
<strong>Zur Nacharbeit zurückgegeben:</strong>
<p className="notiz-anzeige" style={{ marginTop: '0.4rem' }}>
{bericht.anmerkung}
</p>
</div>
) : null}
{bericht.status === 'abgezeichnet' && bericht.anmerkung ? (
<div className="karte">
<strong>Anmerkung des Ausbilders</strong>
<p className="notiz-anzeige" style={{ marginTop: '0.4rem' }}>
{bericht.anmerkung}
</p>
</div>
) : null}
{eigenes ? (
<Blattformular
berichtId={bericht.id}
anfangsblatt={blatt}
vorschlaege={vorschlaege}
status={bericht.status}
/>
) : (
<AnsichtFuerAusbilder blatt={blatt} warnungen={warnungen} />
)}
{/* --- Werkzeuge --- */}
<div className="reihe nicht-drucken">
<a href={`/berichtsheft/${bericht.id}/pdf`} className="knopf knopf-zweit" target="_blank" rel="noreferrer">
Dieses Blatt als PDF
</a>
{eigenes && bericht.status === 'eingereicht' ? (
<form action={blattZurueckziehen}>
<input type="hidden" name="berichtId" value={bericht.id} />
<button type="submit" className="knopf knopf-still">
Einreichung zurückziehen
</button>
</form>
) : null}
</div>
{!eigenes ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="abzeichnen">
<h2 id="abzeichnen" style={{ marginBottom: 0 }}>
Abzeichnen
</h2>
<Abzeichnen berichtId={bericht.id} anmerkung={bericht.anmerkung ?? ''} status={bericht.status} />
</section>
) : null}
{eigenes ? <Formulierungshilfe /> : null}
</article>
);
}
/** Die Nur-Lese-Ansicht für den Ausbilder – dieselben Daten, ohne Formular. */
function AnsichtFuerAusbilder({
blatt,
warnungen,
}: {
blatt: ReturnType<typeof alsWochenblatt>;
warnungen: ReturnType<typeof pruefeWochenblatt>;
}) {
return (
<div className="stapel">
<section className="karte stapel-eng" aria-labelledby="betrieb-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="betrieb-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Betriebliche Tätigkeiten und Unterweisungen
</h2>
<span className="leise">{zahlText(summeBetrieb(blatt))} h</span>
</div>
{blatt.betrieb.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Keine Eintragung.
</p>
) : (
<ul className="leseliste">
{blatt.betrieb.map((eintrag, stelle) => (
<li key={stelle}>
<span>{eintrag.text}</span>
<span className="leise">{eintrag.stunden > 0 ? `${zahlText(eintrag.stunden)} h` : '–'}</span>
</li>
))}
</ul>
)}
</section>
<section className="karte stapel-eng" aria-labelledby="schule-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="schule-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Berufsschule
</h2>
<span className="leise">{zahlText(summeSchule(blatt))} h</span>
</div>
{blatt.schule.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Keine Eintragung.
</p>
) : (
<ul className="leseliste">
{blatt.schule.map((eintrag, stelle) => (
<li key={stelle}>
<span>
{eintrag.lernfeld ? <strong>{eintrag.lernfeld} </strong> : null}
{eintrag.themen}
</span>
<span className="leise">{eintrag.stunden > 0 ? `${zahlText(eintrag.stunden)} h` : '–'}</span>
</li>
))}
</ul>
)}
</section>
{blatt.sonstiges ? (
<section className="karte stapel-eng" aria-labelledby="sonstiges-lesen">
<h2 id="sonstiges-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Sonstiges
</h2>
<p className="notiz-anzeige">{blatt.sonstiges}</p>
</section>
) : null}
<section className="karte stapel-eng" aria-labelledby="pruefung-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="pruefung-lesen" 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>
)}
</section>
</div>
);
}
/** Stellt zusammen, was die Anwendung über diese Woche weiß. */
function sammleVorschlaege(azubiId: string, von: string, bis: string) {
const alle = inhalte();
const notizen = notizenIm(azubiId, von, bis).map((n) => ({
datum: n.datum,
text: n.text,
stunden: n.stunden,
}));
// Praxisaufgaben, die in dieser Woche abgegeben oder abgenommen wurden.
const staende = aufgabenStaende(azubiId);
const aufgaben = [...staende.values()]
.filter((s) => {
const zeitpunkt = (s.abgenommenAm ?? s.abgegebenAm ?? '').slice(0, 10);
return zeitpunkt >= von && zeitpunkt <= bis;
})
.map((s) => {
const aufgabe = alle.aufgabeNach.get(s.aufgabeId);
return {
id: s.aufgabeId,
titel: aufgabe?.titel ?? s.aufgabeId,
zeitrahmen: aufgabe?.zeitrahmen ?? '',
status: s.status,
};
});
const checklisten = laeufeVon(azubiId)
.filter((l) => l.abgeschlossenAm !== null && l.datum >= von && l.datum <= bis)
.map((l) => ({ id: l.id, titel: l.titel, ticket: l.ticket, kunde: l.kunde }));
// Handouts der Wochen, die in diesem Zeitraum lagen – nur die gelesenen.
const ausbildung = ausbildungNachAzubi(azubiId);
const gelesen = gelesenSet(azubiId);
const wochenImZeitraum = ausbildung
? planVon(ausbildung.id).filter((z) => z.geplantVon <= bis && z.geplantBis >= von)
: [];
const handouts = wochenImZeitraum
.flatMap((z) => alle.wocheNach.get(z.wochenNummer)?.handouts ?? [])
.filter((id) => gelesen.has(id))
.map((id) => ({ id, titel: alle.handoutNach.get(id)?.titel ?? id }));
const lernfelder = [
...new Set(
wochenImZeitraum
.flatMap((z) => alle.wocheNach.get(z.wochenNummer)?.handouts ?? [])
.map((id) => alle.handoutNach.get(id)?.lernfeld)
.filter((lf): lf is string => Boolean(lf)),
),
].map((id) => ({ id, titel: alle.lernfeldNach.get(id)?.titel ?? id }));
const rohdaten: Rohdaten = { tagesnotizen: notizen, aufgaben, checklisten, handouts, lernfelder };
return baueVorschlaege(rohdaten);
}
function zahlText(wert: number): string {
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
}
@@ -0,0 +1,52 @@
import { benutzerNachId } from '@/lib/repos/benutzer';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { alsWochenblatt } from '@/lib/repos/berichtsheft';
import { berichtsheftPdf } from '@/lib/pdf/berichtsheft';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
import { blattZumLesen } from '../../aktionen';
export const dynamic = 'force-dynamic';
/** Ein einzelnes Wochenblatt als PDF. Rechteprüfung über `blattZumLesen`. */
export async function GET(_anfrage: Request, kontext: { params: Promise<{ berichtId: string }> }) {
const { berichtId } = await kontext.params;
const gelesen = await blattZumLesen(berichtId);
if (!gelesen) return new Response('Nicht gefunden.', { status: 404 });
const { bericht } = gelesen;
const azubi = benutzerNachId(bericht.benutzerId);
const ausbildung = ausbildungNachAzubi(bericht.benutzerId);
const ausbilder = ausbildung ? benutzerNachId(ausbildung.ausbilderId) : null;
const abzeichner = bericht.abgezeichnetVon ? benutzerNachId(bericht.abgezeichnetVon) : null;
const pdf = await berichtsheftPdf([
{
nr: bericht.nr,
von: alsDatumText(bericht.von),
bis: alsDatumText(bericht.bis),
azubi: azubi?.name ?? 'unbekannt',
ausbilder: ausbilder?.name ?? '',
beruf: ausbildung?.beruf ?? 'Fachinformatiker/-in für Systemintegration',
ausbildungsjahr: ausbildung ? ausbildungsjahr(ausbildung.beginn, bericht.von) : null,
status: bericht.status,
anmerkung: bericht.anmerkung,
abgezeichnetVon: abzeichner?.name ?? null,
abgezeichnetAm: bericht.abgezeichnetAm ? alsZeitpunktText(bericht.abgezeichnetAm) : null,
blatt: alsWochenblatt(bericht),
},
]);
const dateiname = `Ausbildungsnachweis_${String(bericht.nr).padStart(3, '0')}_${bericht.von}.pdf`;
return new Response(new Uint8Array(pdf), {
headers: {
'Content-Type': 'application/pdf',
'Content-Length': String(pdf.byteLength),
'Content-Disposition': `inline; filename="${dateiname}"`,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
});
}
@@ -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;
}
+256
View File
@@ -0,0 +1,256 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { alsWochenblatt, berichteVon, fehlendeWochen, notizenIm } from '@/lib/repos/berichtsheft';
import { summeStunden } from '@/lib/berichtsheft/pruefung';
import { alsDatumText, heute, kalenderwocheText, montagDerWoche, werktage, wochentagText } from '@/lib/zeit';
import { Tagesnotizen, type Tagesfeld } from './Tagesnotizen';
import { Formulierungshilfe } from './Formulierungshilfe';
import { blattAnlegen } from './aktionen';
import { BerichtsMarke } from './BerichtsMarke';
export const metadata: Metadata = { title: 'Berichtsheft' };
export const dynamic = 'force-dynamic';
export default async function BerichtsheftSeite() {
const benutzer = await erfordereBenutzer();
return benutzer.rolle === 'ausbilder' ? (
<AusbilderSicht ausbilderId={benutzer.id} />
) : (
<AzubiSicht azubiId={benutzer.id} />
);
}
// --- Azubi -----------------------------------------------------------------
function AzubiSicht({ azubiId }: { azubiId: string }) {
const ausbildung = ausbildungNachAzubi(azubiId);
const berichte = berichteVon(azubiId);
const heuteDatum = heute();
const tage = werktage(heuteDatum);
const notizen = notizenIm(azubiId, tage[0]!, tage[4]!);
const notizNach = new Map(notizen.map((n) => [n.datum, n]));
const felder: Tagesfeld[] = tage.map((datum) => ({
datum,
wochentag: wochentagText(datum),
datumText: alsDatumText(datum),
text: notizNach.get(datum)?.text ?? '',
stunden: notizNach.get(datum)?.stunden ?? 0,
istHeute: datum === heuteDatum,
}));
const dieseWoche = berichte.find((b) => b.von === montagDerWoche(heuteDatum));
const fehlend = ausbildung ? fehlendeWochen(azubiId, ausbildung.beginn, heuteDatum) : [];
const abgezeichnet = berichte.filter((b) => b.status === 'abgezeichnet').length;
return (
<div className="stapel">
<div>
<h1>Berichtsheft</h1>
<p className="leise">
{berichte.length} {berichte.length === 1 ? 'Wochenblatt' : 'Wochenblätter'} · {abgezeichnet} abgezeichnet
{fehlend.length > 0 ? ` · ${fehlend.length} Wochen ohne Blatt` : ''}
</p>
</div>
{fehlend.length > 0 ? (
<div className="hinweis hinweis-warnung">
<strong>
Für {fehlend.length} vergangene {fehlend.length === 1 ? 'Woche' : 'Wochen'} gibt es noch kein Blatt.
</strong>{' '}
Ohne geführten Nachweis gibt es keine Zulassung zur Abschlussprüfung – das ist der Teil, der in der
Praxis am häufigsten schiefgeht.
<div className="reihe" style={{ marginTop: '0.6rem' }}>
{fehlend.slice(0, 6).map((montag) => (
<form key={montag} action={blattAnlegen}>
<input type="hidden" name="datum" value={montag} />
<button type="submit" className="knopf knopf-zweit">
{kalenderwocheText(montag)} nachtragen
</button>
</form>
))}
{fehlend.length > 6 ? <span className="leise">… und {fehlend.length - 6} weitere</span> : null}
</div>
</div>
) : null}
<section className="karte-grund stapel" aria-labelledby="notizen">
<div>
<h2 id="notizen" style={{ marginBottom: '0.15rem' }}>
Diese Woche in Stichworten
</h2>
<p className="leise" style={{ margin: 0 }}>
Zwei Minuten am Tag. Freitags baust du daraus mit einem Klick das Wochenblatt – das ist deutlich
weniger Arbeit, als sich am Freitag an den Dienstag zu erinnern.
</p>
</div>
<Tagesnotizen tage={felder} />
</section>
<section className="stapel" aria-labelledby="blaetter">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="blaetter" style={{ margin: 0 }}>
Wochenblätter
</h2>
{dieseWoche ? (
<Link href={`/berichtsheft/${dieseWoche.id}`} className="knopf">
Blatt dieser Woche öffnen
</Link>
) : (
<form action={blattAnlegen}>
<input type="hidden" name="datum" value={heuteDatum} />
<button type="submit" className="knopf knopf-akzent">
Blatt für diese Woche anlegen
</button>
</form>
)}
</div>
{berichte.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch kein Wochenblatt. Leg das erste an, sobald die Woche läuft.
</p>
) : (
<ul className="blattliste">
{berichte.map((bericht) => {
const blatt = alsWochenblatt(bericht);
return (
<li key={bericht.id}>
<Link href={`/berichtsheft/${bericht.id}`} className="blattzeile-link">
<span className="blatt-nr">Nr. {bericht.nr}</span>
<span className="blatt-zeitraum">
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
<span className="leise" style={{ display: 'block' }}>
{kalenderwocheText(bericht.von)} · {blatt.betrieb.length} Tätigkeiten ·{' '}
{zahlText(summeStunden(blatt))} h
</span>
</span>
<BerichtsMarke status={bericht.status} />
</Link>
</li>
);
})}
</ul>
)}
</section>
{berichte.length > 0 ? (
<section className="karte stapel-eng nicht-drucken" aria-labelledby="export">
<h2 id="export" style={{ marginBottom: 0, fontSize: '1rem' }}>
Ganzen Zeitraum ausgeben
</h2>
<p className="leise" style={{ margin: 0 }}>
Alle Blätter als ein PDF, ein Blatt je Seite – zum Ausdrucken und Abheften.
</p>
<div>
<a
href={`/berichtsheft/pdf?von=${berichte.at(-1)!.von}&bis=${berichte[0]!.bis}`}
className="knopf knopf-zweit"
target="_blank"
rel="noreferrer"
>
Alle {berichte.length} Blätter als PDF
</a>
</div>
</section>
) : null}
<Formulierungshilfe />
</div>
);
}
// --- Ausbilder -------------------------------------------------------------
function AusbilderSicht({ ausbilderId }: { ausbilderId: string }) {
const meine = ausbildungenVonAusbilder(ausbilderId);
return (
<div className="stapel">
<div>
<h1>Berichtshefte</h1>
<p className="leise">Eingereichte Blätter abzeichnen oder zur Nacharbeit zurückgeben.</p>
</div>
{meine.length === 0 ? (
<p className="hinweis hinweis-warnung">
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
meine.map(({ azubi, ausbildung }) => {
const berichte = berichteVon(azubi.id);
const offene = berichte.filter((b) => b.status === 'eingereicht');
const fehlend = fehlendeWochen(azubi.id, ausbildung.beginn);
return (
<section key={azubi.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
{azubi.name}
</h2>
<div className="reihe">
{offene.length > 0 ? (
<span className="marke-status" data-ton="warnung">
● {offene.length} zum Abzeichnen
</span>
) : null}
{fehlend.length > 0 ? (
<span className="marke-status" data-ton="fehler">
! {fehlend.length} Wochen ohne Blatt
</span>
) : null}
{berichte.length > 0 ? (
<a
href={`/berichtsheft/pdf?azubi=${azubi.id}&von=${berichte.at(-1)!.von}&bis=${berichte[0]!.bis}`}
className="knopf knopf-zweit"
target="_blank"
rel="noreferrer"
>
Alles als PDF
</a>
) : null}
</div>
</div>
{berichte.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch kein Wochenblatt.
</p>
) : (
<ul className="blattliste">
{berichte.map((bericht) => {
const blatt = alsWochenblatt(bericht);
return (
<li key={bericht.id}>
<Link href={`/berichtsheft/${bericht.id}`} className="blattzeile-link">
<span className="blatt-nr">Nr. {bericht.nr}</span>
<span className="blatt-zeitraum">
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
<span className="leise" style={{ display: 'block' }}>
{blatt.betrieb.length} Tätigkeiten · {zahlText(summeStunden(blatt))} h
</span>
</span>
<BerichtsMarke status={bericht.status} />
</Link>
</li>
);
})}
</ul>
)}
</section>
);
})
)}
</div>
);
}
function zahlText(wert: number): string {
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
}
@@ -0,0 +1,77 @@
import { erfordereBenutzer, ausbildungVonAzubi } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { alsWochenblatt, berichteVon } from '@/lib/repos/berichtsheft';
import { berichtsheftPdf, type Blattangaben } from '@/lib/pdf/berichtsheft';
import { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
export const dynamic = 'force-dynamic';
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/**
* Ein ganzer Zeitraum als PDF, ein Blatt je Seite.
*
* /berichtsheft/pdf?von=2026-08-01&bis=2026-12-31[&azubi=…]
*
* Ohne `azubi` das eigene Berichtsheft. Der Ausbilder gibt die Kennung an –
* geprüft wird sie serverseitig gegen das Ausbildungsverhältnis.
*/
export async function GET(anfrage: Request) {
const benutzer = await erfordereBenutzer();
const adresse = new URL(anfrage.url);
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (adresse.searchParams.get('azubi') ?? '');
if (!azubiId) return new Response('Nicht gefunden.', { status: 404 });
const bezug = ausbildungVonAzubi(azubiId);
if (!darfAzubiDatenSehen(benutzer, azubiId, bezug)) {
return new Response('Nicht gefunden.', { status: 404 });
}
const von = adresse.searchParams.get('von') ?? '';
const bis = adresse.searchParams.get('bis') ?? '';
if (!datumMuster.test(von) || !datumMuster.test(bis) || von > bis) {
return new Response('Ungültiger Zeitraum.', { status: 400 });
}
const azubi = benutzerNachId(azubiId);
const ausbilder = bezug ? benutzerNachId(bezug.ausbilderId) : null;
// Aufsteigend, damit das Heft in der richtigen Reihenfolge ausgedruckt wird.
const berichte = berichteVon(azubiId)
.filter((b) => b.bis >= von && b.von <= bis)
.sort((a, b) => a.nr - b.nr);
const blaetter: Blattangaben[] = berichte.map((bericht) => {
const abzeichner = bericht.abgezeichnetVon ? benutzerNachId(bericht.abgezeichnetVon) : null;
return {
nr: bericht.nr,
von: alsDatumText(bericht.von),
bis: alsDatumText(bericht.bis),
azubi: azubi?.name ?? 'unbekannt',
ausbilder: ausbilder?.name ?? '',
beruf: bezug?.beruf ?? 'Fachinformatiker/-in für Systemintegration',
ausbildungsjahr: bezug ? ausbildungsjahr(bezug.beginn, bericht.von) : null,
status: bericht.status,
anmerkung: bericht.anmerkung,
abgezeichnetVon: abzeichner?.name ?? null,
abgezeichnetAm: bericht.abgezeichnetAm ? alsZeitpunktText(bericht.abgezeichnetAm) : null,
blatt: alsWochenblatt(bericht),
};
});
const pdf = await berichtsheftPdf(blaetter, `${alsDatumText(von)} bis ${alsDatumText(bis)}`);
const dateiname = `Ausbildungsnachweis_${von}_bis_${bis}.pdf`;
return new Response(new Uint8Array(pdf), {
headers: {
'Content-Type': 'application/pdf',
'Content-Length': String(pdf.byteLength),
'Content-Disposition': `inline; filename="${dateiname}"`,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
});
}
+2
View File
@@ -12,6 +12,7 @@ const fuerAzubi: Navigationseintrag[] = [
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/aufgaben', titel: 'Aufgaben' },
{ pfad: '/checklisten', titel: 'Checklisten' },
{ pfad: '/berichtsheft', titel: 'Berichtsheft' },
{ pfad: '/glossar', titel: 'Glossar' },
];
@@ -22,6 +23,7 @@ const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/aufgaben', titel: 'Aufgaben' },
{ pfad: '/checklisten', titel: 'Checklisten' },
{ pfad: '/berichtsheft', titel: 'Berichtshefte' },
{ pfad: '/glossar', titel: 'Glossar' },
{ pfad: '/verwaltung', titel: 'Verwaltung' },
];
+22 -4
View File
@@ -6,6 +6,7 @@ import { aktuelleWochensicht, kurzstand } from '@/lib/planung/sicht';
import { alsDatumText, alsZeitpunktText, heute, kalenderwocheText } from '@/lib/zeit';
import { inhalte } from '@/lib/inhalte';
import { wartetAufAbnahme } from '@/lib/repos/aufgaben';
import { wartetAufAbzeichnung } from '@/lib/repos/berichtsheft';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { WochenAnsicht } from './WochenAnsicht';
@@ -148,7 +149,10 @@ function AusbilderUeberblick({ ausbilderId, name }: { ausbilderId: string; name:
function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
const meine = ausbildungenVonAusbilder(ausbilderId);
const alle = inhalte();
const abnahmen = wartetAufAbnahme(meine.map(({ azubi }) => azubi.id));
const azubiIds = meine.map(({ azubi }) => azubi.id);
const abnahmen = wartetAufAbnahme(azubiIds);
const berichte = wartetAufAbzeichnung(azubiIds);
const gesamt = abnahmen.length + berichte.length;
return (
<section className="karte stapel-eng" aria-labelledby="wartet">
@@ -156,13 +160,27 @@ function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
Was auf dich wartet
</h2>
{abnahmen.length === 0 ? (
{gesamt === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Nichts zur Abnahme. Unkorrigierte Lernzielkontrollen und nicht abgezeichnete Berichtshefte kommen
hier dazu, sobald diese Bausteine stehen.
Nichts offen. Unkorrigierte Lernzielkontrollen kommen hier dazu, sobald dieser Baustein steht.
</p>
) : (
<ul className="offen-liste">
{berichte.map((bericht) => {
const azubi = benutzerNachId(bericht.benutzerId);
return (
<li key={bericht.id}>
<span>
<Link href={`/berichtsheft/${bericht.id}`}>
<strong>Berichtsheft Nr. {bericht.nr}</strong> abzeichnen
</Link>
{azubi ? <span className="leise"> · {azubi.name}</span> : null}
</span>
<span className="leise">eingereicht {alsZeitpunktText(bericht.eingereichtAm)}</span>
</li>
);
})}
{abnahmen.map((stand) => {
const aufgabe = alle.aufgabeNach.get(stand.aufgabeId);
const azubi = benutzerNachId(stand.benutzerId);
+205
View File
@@ -1341,3 +1341,208 @@ input[type="file"] {
.schritt-notiz[open] > form { display: none; }
.hakenknopf { border-color: #666; }
}
/* --- Berichtsheft --- */
.tagesnotizen {
display: grid;
gap: 0.6rem;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
}
.tagesnotiz {
display: grid;
gap: 0.35rem;
background: var(--flaeche);
border: 1px solid var(--rand-zart);
border-left: 4px solid var(--rand);
border-radius: var(--radius-klein);
padding: 0.6rem 0.7rem;
}
.tagesnotiz-heute { border-left-color: var(--bernstein); background: var(--warnung-flaeche); }
.tagesnotiz-kopf {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
font-size: 0.9rem;
}
.tagesnotiz textarea { min-height: 4.5rem; font-size: 0.92rem; }
.tagesnotiz-fuss {
display: flex;
align-items: center;
gap: 0.4rem;
}
.tagesnotiz-fuss label { margin: 0; font-size: 0.82rem; white-space: nowrap; }
.tagesnotiz-fuss input { width: 5rem; min-height: 38px; }
.blattliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.45rem; }
.blattzeile-link {
display: flex;
align-items: center;
gap: 0.85rem;
background: var(--flaeche);
border: 1px solid var(--rand-zart);
border-radius: var(--radius);
padding: 0.7rem 0.9rem;
text-decoration: none;
color: var(--text);
}
.blattzeile-link:hover { background: var(--flaeche-kraeftig); color: var(--text); }
.blatt-zeitraum { flex: 1; font-weight: 650; }
.blatt-nr {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 3.4rem;
padding: 0.15rem 0.55rem;
border-radius: var(--radius-klein);
background: var(--petrol);
color: var(--text-auf-petrol);
font-weight: 700;
font-size: 0.82rem;
flex-shrink: 0;
}
.blatt-nr-gross { font-size: 0.95rem; padding: 0.25rem 0.7rem; }
/* Zeilen im Wochenblatt */
.blattzeile {
display: grid;
grid-template-columns: 1fr 6rem 2.5rem;
gap: 0.6rem;
align-items: start;
}
.blattzeile:has(.blattzeile-lernfeld) { grid-template-columns: 6rem 1fr 6rem 2.5rem; }
.blattzeile textarea { min-height: 3.6rem; }
.blattzeile-stunden label { font-size: 0.8rem; }
.zeile-weg { margin-top: 1.1rem; min-height: 38px; padding: 0.2rem 0.5rem; font-size: 1.1rem; }
@media (max-width: 640px) {
.blattzeile,
.blattzeile:has(.blattzeile-lernfeld) { grid-template-columns: 1fr 5rem; }
.zeile-weg { grid-column: 2; margin-top: 0; justify-self: end; }
.blattzeile-lernfeld { grid-column: 1 / -1; }
}
/* Vorschläge */
.vorschlagsliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
.vorschlag {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-areas: "quelle text plus" "quelle herkunft plus";
gap: 0.15rem 0.6rem;
width: 100%;
text-align: left;
font: inherit;
color: var(--text);
background: var(--grund);
border: 1px solid var(--rand);
border-radius: var(--radius-klein);
padding: 0.55rem 0.7rem;
cursor: pointer;
align-items: center;
}
.vorschlag:hover { border-color: var(--petrol); background: var(--flaeche); }
.vorschlag-quelle {
grid-area: quelle;
align-self: start;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.4rem;
border-radius: 3px;
background: var(--flaeche-kraeftig);
color: var(--text-leise);
white-space: nowrap;
}
.vorschlag-quelle[data-quelle="tagesnotiz"] { background: var(--warnung-flaeche); color: var(--warnung); }
.vorschlag-quelle[data-quelle="aufgabe"] { background: var(--gut-flaeche); color: var(--gut); }
.vorschlag-text { grid-area: text; font-weight: 600; line-height: 1.4; }
.vorschlag .leise { grid-area: herkunft; font-size: 0.8rem; }
.vorschlag-plus { grid-area: plus; font-size: 1.3rem; color: var(--petrol); font-weight: 700; }
/* Warnungen */
.warnliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
.warnliste li {
display: grid;
grid-template-columns: 1.4rem 1fr;
gap: 0.5rem;
align-items: start;
font-size: 0.93rem;
line-height: 1.45;
}
.warnzeichen {
display: grid;
place-items: center;
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
font-weight: 700;
font-size: 0.8rem;
}
.warnliste li[data-schwere="warnung"] .warnzeichen { background: var(--fehler-flaeche); color: var(--fehler); }
.warnliste li[data-schwere="hinweis"] .warnzeichen { background: var(--flaeche-kraeftig); color: var(--text-leise); }
.warnliste em { color: var(--text-leise); font-style: italic; }
.feldwarnung { list-style: none; margin: 0.25rem 0 0; padding: 0; display: grid; gap: 0.15rem; }
.feldwarnung li { font-size: 0.83rem; line-height: 1.35; padding-left: 0.9rem; position: relative; }
.feldwarnung li::before { content: "•"; position: absolute; left: 0; }
.feldwarnung li[data-schwere="warnung"] { color: var(--fehler); }
.feldwarnung li[data-schwere="hinweis"] { color: var(--text-leise); }
textarea[aria-invalid="true"] { border-color: var(--fehler); }
/* Leseansicht */
.leseliste { list-style: none; margin: 0; padding: 0; }
.leseliste li {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.45rem 0;
border-bottom: 1px solid var(--rand-zart);
line-height: 1.45;
}
.leseliste li:last-child { border-bottom: 0; }
.leseliste .leise { white-space: nowrap; font-variant-numeric: tabular-nums; }
/* Formulierungshilfe */
.formulierungshilfe > summary {
cursor: pointer;
list-style: none;
min-height: 44px;
display: flex;
align-items: center;
gap: 0.4rem;
}
.formulierungshilfe > summary::-webkit-details-marker { display: none; }
.formulierungshilfe > summary::before { content: "▸"; color: var(--petrol); }
.formulierungshilfe[open] > summary::before { content: "▾"; }
.verbgruppen { display: grid; gap: 0.6rem; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.gegenueberstellung { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.9rem; }
.gegenueberstellung li {
border-left: 3px solid var(--rand);
padding-left: 0.75rem;
}
.gegenueberstellung p { margin: 0 0 0.25rem; line-height: 1.45; }
.gegenueberstellung .schlecht { color: var(--text-leise); text-decoration: line-through solid var(--fehler) 1px; }
.gegenueberstellung .schlecht .marke-status { text-decoration: none; }
.gegenueberstellung .gut { font-weight: 500; }
@media print {
.tagesnotizen, .vorschlagsliste, .formulierungshilfe { display: none; }
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Formulierungshilfe für das Berichtsheft.
*
* Steht bewusst im Quelltext und nicht unter `content/`: Das ist keine
* Ausbildungsinhalt, sondern Bedienhilfe der Anwendung – sie ändert sich nicht
* mit dem Lehrgang und braucht keine Versionierung neben den Handouts.
*/
import { taetigkeitsverben } from './pruefung';
export { taetigkeitsverben };
/** Verben nach Tätigkeitsfeld gruppiert, damit man das passende schneller findet. */
export const verbgruppen: { titel: string; verben: string[] }[] = [
{
titel: 'Aufbauen und einrichten',
verben: ['installiert', 'konfiguriert', 'eingerichtet', 'angeschlossen', 'verkabelt', 'aufgebaut', 'ausgerollt'],
},
{
titel: 'Prüfen und eingrenzen',
verben: ['geprüft', 'gemessen', 'getestet', 'eingegrenzt', 'überwacht', 'ausgewertet', 'berechnet'],
},
{
titel: 'Instandsetzen',
verben: ['behoben', 'getauscht', 'zerlegt', 'aktualisiert', 'migriert', 'zurückgespielt', 'gesichert'],
},
{
titel: 'Beraten und übergeben',
verben: ['beraten', 'eingewiesen', 'übergeben', 'abgenommen', 'dokumentiert', 'protokolliert'],
},
{
titel: 'Beschaffen und verwalten',
verben: ['bestellt', 'inventarisiert', 'entsorgt', 'geplant', 'programmiert'],
},
];
export type Gegenueberstellung = { schlecht: string; gut: string; warum: string };
/** „So besser nicht – so ist es richtig." */
export const gegenueberstellungen: Gegenueberstellung[] = [
{
schlecht: 'Diverse Arbeiten im Betrieb',
gut: 'Vier Arbeitsplatzrechner nach Checkliste neu aufgesetzt und ins Netz gebracht',
warum: 'Nennt Anzahl, Tätigkeit und Ergebnis. Ein Prüfer kann sich etwas darunter vorstellen.',
},
{
schlecht: 'Wie in der Vorwoche',
gut: 'Weitere zwölf Notebooks aus der Bestellung inventarisiert und mit dem Standardabbild versehen',
warum: 'Auch wenn die Arbeit dieselbe war: Der Umfang war ein anderer, und das gehört ins Blatt.',
},
{
schlecht: 'Beim Kollegen mitgeholfen',
gut: 'Beim Tausch eines defekten Switches assistiert: Konfiguration gesichert, Ports dokumentiert, nach dem Tausch zurückgespielt',
warum: 'Sagt, was du selbst getan hast. „Mitgeholfen" bleibt offen, ob du zugesehen oder gearbeitet hast.',
},
{
schlecht: 'Drucker repariert',
gut: 'Druckerstörung in Halle 2 eingegrenzt: hängender Auftrag in der Warteschlange, Spooler-Dienst neu gestartet, Ursache im Ticket dokumentiert',
warum: 'Zeigt den Weg der Fehlersuche – genau das wird in der Prüfung verlangt.',
},
{
schlecht: 'Netzwerk gemacht',
gut: 'Zwei Netzwerkdosen aufgelegt, mit dem Kabeltester geprüft und im Patchfeld beschriftet',
warum: 'Benennt Handgriffe und Prüfmittel statt eines Sammelbegriffs.',
},
{
schlecht: 'Client 192.168.10.42 neu ins Netz gebracht',
gut: 'Einen Client im Verwaltungsnetz neu eingebunden, Adressbezug über DHCP geprüft',
warum: 'IP-Adressen, Kundennamen und Zugangsdaten gehören nicht in ein Dokument, das herumgereicht wird.',
},
{
schlecht: 'Server geupdated',
gut: 'Sicherheitsaktualisierungen auf dem Dateiserver eingespielt, Dienste nach dem Neustart geprüft',
warum: 'Deutsches Wort, konkreter Gegenstand, und der Prüfschritt danach ist genannt.',
},
{
schlecht: 'Berufsschule',
gut: 'LF3 – Subnetting mit variabler Maskenlänge, Adressvergabe über DHCP',
warum: 'Lernfeld und Thema sind das, was den Schulteil nachvollziehbar macht.',
},
];
/** Kurze Regeln, die über den Beispielen stehen. */
export const regeln: string[] = [
'Schreib im Rückblick: „installiert", nicht „installieren" oder „habe installiert".',
'Nenne Gegenstand und Ergebnis – was hast du angefasst, und was kam dabei heraus?',
'Eine Zeile pro Tätigkeit, mit eigener Stundenangabe.',
'Keine IP-Adressen, Kundennamen, Seriennummern oder Zugangsdaten.',
'Deutsche Wörter, wo es sie gibt: „aktualisiert" statt „geupdated".',
'Urlaub, Krankheit und Feiertage gehören unter „Sonstiges", nicht unter die Tätigkeiten.',
];
+28
View File
@@ -0,0 +1,28 @@
/**
* Ausbildungsjahr zu einem Stichtag.
*
* Das erste Ausbildungsjahr beginnt am Ausbildungsbeginn und dauert zwölf
* Monate. Auf dem Papierformular steht diese Zahl im Kopf jedes Blattes –
* deshalb wird sie gerechnet und nicht von Hand eingetragen.
*/
export function ausbildungsjahr(beginn: string, stichtag: string): number {
const [bJahr, bMonat, bTag] = zerlege(beginn);
const [sJahr, sMonat, sTag] = zerlege(stichtag);
if (
sJahr < bJahr ||
(sJahr === bJahr && (sMonat < bMonat || (sMonat === bMonat && sTag < bTag)))
) {
// Vor dem Beginn – kommt bei nachgetragenen Blättern vor.
return 1;
}
let jahre = sJahr - bJahr;
if (sMonat < bMonat || (sMonat === bMonat && sTag < bTag)) jahre -= 1;
return jahre + 1;
}
function zerlege(datum: string): [number, number, number] {
return [Number(datum.slice(0, 4)), Number(datum.slice(5, 7)), Number(datum.slice(8, 10))];
}
+229
View File
@@ -0,0 +1,229 @@
import { describe, expect, it } from 'vitest';
import {
anzahlErnst,
findeSchutzwuerdiges,
gefundeneFloskel,
pruefeWochenblatt,
summeBetrieb,
summeSchule,
summeStunden,
type Wochenblatt,
} from './pruefung';
function blatt(teil: Partial<Wochenblatt> = {}): Wochenblatt {
return {
betrieb: [
{ text: 'Vier Arbeitsplatzrechner nach Checkliste neu aufgesetzt und ins Netz gebracht.', stunden: 12 },
{ text: 'Störungsmeldungen im Ticketsystem bearbeitet, davon zwei Druckerprobleme behoben.', stunden: 10 },
],
schule: [{ lernfeld: 'LF3', themen: 'Subnetting, Adressvergabe mit DHCP', stunden: 8 }],
sonstiges: '',
...teil,
};
}
describe('Ein sauberes Blatt', () => {
it('gibt keine ernste Warnung', () => {
const warnungen = pruefeWochenblatt(blatt());
expect(anzahlErnst(warnungen)).toBe(0);
});
});
describe('Stunden', () => {
it('meldet einen Eintrag ohne Stunden', () => {
const warnungen = pruefeWochenblatt(
blatt({ betrieb: [{ text: 'Netzwerkdose in Halle 2 aufgelegt und gemessen.', stunden: 0 }] }),
);
expect(warnungen.some((w) => w.art === 'stunden' && w.schwere === 'warnung')).toBe(true);
});
it('meldet keine Stunden für einen leeren Eintrag', () => {
const warnungen = pruefeWochenblatt(
blatt({ betrieb: [...blatt().betrieb, { text: ' ', stunden: 0 }] }),
);
expect(warnungen.filter((w) => w.feld === 'betrieb.2')).toEqual([]);
});
it('meldet einen Eintrag mit Text, aber ohne alles andere', () => {
const warnungen = pruefeWochenblatt(blatt({ betrieb: [{ text: '', stunden: 6 }] }));
expect(warnungen.some((w) => w.art === 'leer' && w.text.includes('keinen Text'))).toBe(true);
});
it('meldet ein völlig leeres Blatt als ernste Warnung', () => {
const warnungen = pruefeWochenblatt(blatt({ betrieb: [], schule: [] }));
expect(warnungen.some((w) => w.art === 'leer' && w.schwere === 'warnung')).toBe(true);
});
it('meldet einen Einzeleintrag über einer ganzen Wochenarbeitszeit', () => {
const warnungen = pruefeWochenblatt(
blatt({ betrieb: [{ text: 'Serverraum vollständig neu verkabelt und dokumentiert.', stunden: 55 }] }),
);
expect(warnungen.some((w) => w.art === 'stunden' && w.text.includes('ganze Woche'))).toBe(true);
});
it('meldet eine unglaubwürdig hohe Wochensumme', () => {
const warnungen = pruefeWochenblatt(
blatt({
betrieb: [
{ text: 'Erste längere Tätigkeit mit ordentlicher Beschreibung.', stunden: 35 },
{ text: 'Zweite längere Tätigkeit mit ordentlicher Beschreibung.', stunden: 35 },
],
schule: [],
}),
);
expect(warnungen.some((w) => w.feld === 'summe' && w.schwere === 'warnung')).toBe(true);
});
it('gibt bei sehr wenigen Stunden nur einen Hinweis, keine Warnung', () => {
const warnungen = pruefeWochenblatt(
blatt({ betrieb: [{ text: 'Arbeitsplatz für neue Kollegin eingerichtet und übergeben.', stunden: 4 }], schule: [] }),
);
const summenwarnung = warnungen.find((w) => w.feld === 'summe');
expect(summenwarnung?.schwere).toBe('hinweis');
});
it('rechnet Betrieb und Schule getrennt und zusammen', () => {
const daten = blatt();
expect(summeBetrieb(daten)).toBe(22);
expect(summeSchule(daten)).toBe(8);
expect(summeStunden(daten)).toBe(30);
});
it('zählt Stunden leerer Einträge nicht mit', () => {
const daten = blatt({ betrieb: [{ text: '', stunden: 99 }] });
expect(summeBetrieb(daten)).toBe(0);
});
});
describe('Floskeln', () => {
it('erkennt die üblichen Verdächtigen', () => {
for (const floskel of ['Diverse Arbeiten', 'Wie in der Vorwoche', 'Wie gehabt', 'Siehe oben', 'Tagesgeschäft']) {
expect(gefundeneFloskel(floskel), floskel).not.toBeNull();
}
});
it('meldet einen Floskeleintrag als ernste Warnung', () => {
const warnungen = pruefeWochenblatt(blatt({ betrieb: [{ text: 'Diverse Arbeiten', stunden: 8 }] }));
expect(warnungen.some((w) => w.art === 'floskel' && w.schwere === 'warnung')).toBe(true);
});
it('lässt einen echten Eintrag in Ruhe, auch wenn ein Floskelwort darin vorkommt', () => {
// „Diverse" allein macht keine Floskel – hier steht eine echte Tätigkeit.
expect(
gefundeneFloskel('Diverse Patchkabel konfektioniert, gemessen und im Verteiler beschriftet.'),
).toBeNull();
});
it('greift trotzdem, wenn der Eintrag lang ist, aber kein Tätigkeitsverb enthält', () => {
expect(
gefundeneFloskel('Diverse Arbeiten im Betrieb an mehreren Stellen im Haus über den ganzen Tag verteilt.'),
).not.toBeNull();
});
it('ist unempfindlich gegen Groß- und Kleinschreibung und Umlaute', () => {
expect(gefundeneFloskel('BÜROTÄTIGKEITEN')).not.toBeNull();
expect(gefundeneFloskel('verschiedene tätigkeiten')).not.toBeNull();
});
it('gibt bei sehr kurzen Einträgen einen Hinweis', () => {
const warnungen = pruefeWochenblatt(blatt({ betrieb: [{ text: 'Drucker', stunden: 3 }] }));
expect(warnungen.some((w) => w.art === 'floskel' && w.schwere === 'hinweis')).toBe(true);
});
});
describe('Angaben, die nicht ins Berichtsheft gehören', () => {
it('findet eine IP-Adresse', () => {
const funde = findeSchutzwuerdiges('Client auf 192.168.10.200 neu ins Netz gebracht.');
expect(funde).toHaveLength(1);
expect(funde[0]!.auszug).toBe('192.168.10.200');
});
it('findet mehrere IP-Adressen einzeln', () => {
const funde = findeSchutzwuerdiges('Von 10.0.0.5 nach 10.0.0.9 umgezogen.');
expect(funde.map((f) => f.auszug)).toEqual(['10.0.0.5', '10.0.0.9']);
});
it('hält eine Versionsnummer nicht für eine IP-Adresse', () => {
expect(findeSchutzwuerdiges('Treiber 4.12.3 eingespielt.')).toEqual([]);
});
it('findet eine MAC-Adresse', () => {
const funde = findeSchutzwuerdiges('Reservierung für 00:1B:44:11:3A:B7 angelegt.');
expect(funde[0]!.auszug).toBe('00:1B:44:11:3A:B7');
});
it('findet eine E-Mail-Adresse', () => {
const funde = findeSchutzwuerdiges('Konto fuer m.mueller@kunde-xy.de eingerichtet.');
expect(funde[0]!.auszug).toBe('m.mueller@kunde-xy.de');
});
it('findet Zugangsdaten', () => {
const funde = findeSchutzwuerdiges('Passwort: Sommer2026! gesetzt.');
expect(funde[0]!.text).toContain('Zugangsdaten');
expect(funde[0]!.schwere).toBe('warnung');
});
it('findet einen Kundennamen an der Rechtsform', () => {
for (const name of ['Müller Metallbau GmbH', 'Schmidt AG', 'Weber GmbH & Co. KG', 'Beispiel e. V.']) {
const funde = findeSchutzwuerdiges(`Vor Ort bei ${name} gewesen.`);
expect(funde.length, name).toBeGreaterThan(0);
expect(funde[0]!.text, name).toContain('Kundennamen');
}
});
it('hält eine gewöhnliche Tätigkeit nicht für einen Kundennamen', () => {
expect(findeSchutzwuerdiges('Vier Rechner nach Checkliste aufgesetzt und übergeben.')).toEqual([]);
});
it('gibt bei einer Telefonnummer nur einen Hinweis', () => {
const funde = findeSchutzwuerdiges('Rückruf unter 0911 1234567 vereinbart.');
expect(funde[0]?.schwere).toBe('hinweis');
});
it('prüft auch Berufsschulthemen und Sonstiges', () => {
const warnungen = pruefeWochenblatt(
blatt({
schule: [{ lernfeld: 'LF3', themen: 'Adressierung am Beispiel 192.168.1.1', stunden: 8 }],
sonstiges: 'Termin bei Müller Metallbau GmbH begleitet.',
}),
);
expect(warnungen.filter((w) => w.art === 'daten').map((w) => w.feld)).toEqual(
expect.arrayContaining(['schule.0', 'sonstiges']),
);
});
it('nennt denselben Fund nicht doppelt', () => {
const funde = findeSchutzwuerdiges('10.0.0.5 gepingt, dann 10.0.0.5 erneut geprüft.');
expect(funde).toHaveLength(1);
});
});
describe('Reihenfolge und Zusammenfassung', () => {
it('stellt ernste Warnungen nach vorn', () => {
const warnungen = pruefeWochenblatt(
blatt({
betrieb: [
{ text: 'Drucker', stunden: 2 }, // nur Hinweis
{ text: 'Diverse Arbeiten', stunden: 8 }, // Warnung
],
}),
);
expect(warnungen[0]!.schwere).toBe('warnung');
});
it('zählt nur die ernsten Warnungen', () => {
const warnungen = pruefeWochenblatt(blatt({ betrieb: [{ text: 'Drucker', stunden: 2 }] }));
expect(anzahlErnst(warnungen)).toBeLessThan(warnungen.length);
});
it('nennt bei jeder Warnung das betroffene Feld', () => {
const warnungen = pruefeWochenblatt(blatt({ betrieb: [{ text: 'Diverse Arbeiten', stunden: 0 }] }));
expect(warnungen.every((w) => w.feld !== undefined)).toBe(true);
});
it('ist eine Warnung, keine Sperre – das Blatt bleibt gültig', () => {
// Diese Prüfung liefert nur Text. Es gibt bewusst kein "darfEinreichen".
const warnungen = pruefeWochenblatt(blatt({ betrieb: [{ text: 'Diverse Arbeiten', stunden: 0 }] }));
expect(Array.isArray(warnungen)).toBe(true);
});
});
+376
View File
@@ -0,0 +1,376 @@
/**
* Prüfung eines Wochenblatts vor dem Einreichen.
*
* Grundsatz: **Warnung, keine Sperre.** Der Azubi kann jede Warnung übergehen –
* er muss sie nur gesehen haben. Eine Anwendung, die das Einreichen verhindert,
* führt dazu, dass jemand Unsinn hineinschreibt, um die Prüfung zu befriedigen.
*
* Geprüft wird auf drei Dinge:
* 1. fehlende oder unglaubwürdige Stundenangaben,
* 2. Floskeln, die nichts aussagen („diverse Arbeiten"),
* 3. Angaben, die nicht ins Berichtsheft gehören – IP-Adressen, Passwörter,
* Kunden- und Personennamen.
*/
export type Eintrag = {
text: string;
stunden: number;
};
export type Schulwoche = {
lernfeld: string;
themen: string;
stunden: number;
};
export type Wochenblatt = {
betrieb: Eintrag[];
schule: Schulwoche[];
sonstiges: string;
};
export type Warnungsart = 'stunden' | 'floskel' | 'daten' | 'leer';
export type Warnung = {
art: Warnungsart;
/** `hinweis` ist ein Nebensatz, `warnung` sollte man ernst nehmen. */
schwere: 'hinweis' | 'warnung';
text: string;
/** Wo im Blatt – für die Anzeige am richtigen Feld. */
feld?: string;
/** Der Textausschnitt, um den es geht. */
auszug?: string;
};
/** Übliche Wochenarbeitszeit in der Ausbildung – Grundlage der Plausibilität. */
export const REGELSTUNDEN = 40;
// --- Floskeln --------------------------------------------------------------
/**
* Wendungen, die im Berichtsheft nichts aussagen.
*
* Sie stehen hier als Liste und nicht als Regelwerk, weil jede einzelne eine
* bewusste Entscheidung ist. „Diverse Arbeiten" sagt nichts; „diverse Kabel
* konfektioniert" dagegen schon – deshalb greift die Prüfung nur, wenn die
* Floskel den Eintrag im Wesentlichen ausmacht.
*/
export const floskeln: string[] = [
'diverse arbeiten',
'diverses',
'verschiedene arbeiten',
'verschiedene taetigkeiten',
'allgemeine arbeiten',
'wie in der vorwoche',
'wie vorwoche',
'wie letzte woche',
'wie immer',
'wie gehabt',
'wie oben',
'siehe oben',
'siehe vorwoche',
'tagesgeschaeft',
'buerotaetigkeiten',
'mitgeholfen',
'zugearbeitet',
'unterstuetzt',
'sonstige arbeiten',
'arbeiten im betrieb',
'im betrieb geholfen',
];
/** Wörter, die einen Eintrag stark machen – Tätigkeitsverben. */
export const taetigkeitsverben: string[] = [
'installiert',
'konfiguriert',
'eingerichtet',
'angeschlossen',
'verkabelt',
'getauscht',
'aufgebaut',
'zerlegt',
'geprüft',
'gemessen',
'getestet',
'eingegrenzt',
'behoben',
'dokumentiert',
'protokolliert',
'gesichert',
'zurückgespielt',
'aktualisiert',
'überwacht',
'ausgewertet',
'berechnet',
'geplant',
'beraten',
'eingewiesen',
'übergeben',
'abgenommen',
'bestellt',
'inventarisiert',
'entsorgt',
'programmiert',
'ausgerollt',
'migriert',
];
// --- Muster für schützenswerte Angaben -------------------------------------
const ipv4 = /\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b/g;
const macAdresse = /\b(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b/gi;
const emailAdresse = /\b[\w.%+-]+@[\w.-]+\.[a-z]{2,}\b/gi;
// Der Abschluss ist bewusst ein negativer Vorausblick statt \b: Rechtsformen
// wie „e. V." enden auf einen Punkt, und nach einem Punkt gibt es vor einem
// Leerzeichen keine Wortgrenze – mit \b wäre genau dieser Fall durchgerutscht.
const rechtsform =
/\b[A-ZÄÖÜ][\wÄÖÜäöüß.&-]*(?:\s+[A-ZÄÖÜ][\wÄÖÜäöüß.&-]*){0,3}\s+(?:GmbH(?:\s*&\s*Co\.?\s*KG)?|AG|KG|OHG|GbR|mbH|SE|Ltd\.?|Inc\.?|e\.\s?V\.)(?![\wÄÖÜäöüß])/g;
const geheimnis = /\b(?:passw(?:ort|örter)|kennwort|pin|zugangsdaten|benutzername|login|token|schlüssel)\b\s*[:=]\s*\S+/gi;
const telefonnummer = /\b(?:\+49|0)\s?\d{2,5}[\s/-]?\d{3,}\b/g;
// --- Prüfung ---------------------------------------------------------------
export function pruefeWochenblatt(blatt: Wochenblatt): Warnung[] {
const warnungen: Warnung[] = [
...pruefeVollstaendigkeit(blatt),
...pruefeStunden(blatt),
...pruefeFloskeln(blatt),
...pruefeSchutzwuerdiges(blatt),
];
// Warnungen vor Hinweisen, sonst Reihenfolge des Auftretens.
return warnungen.sort((a, b) => (a.schwere === b.schwere ? 0 : a.schwere === 'warnung' ? -1 : 1));
}
function pruefeVollstaendigkeit(blatt: Wochenblatt): Warnung[] {
const warnungen: Warnung[] = [];
const echteEintraege = blatt.betrieb.filter((e) => e.text.trim() !== '');
if (echteEintraege.length === 0) {
warnungen.push({
art: 'leer',
schwere: 'warnung',
text: 'Es steht keine einzige betriebliche Tätigkeit im Blatt. Ohne Inhalt ist der Nachweis wertlos.',
feld: 'betrieb',
});
}
for (const [stelle, eintrag] of blatt.betrieb.entries()) {
if (eintrag.text.trim() === '' && eintrag.stunden > 0) {
warnungen.push({
art: 'leer',
schwere: 'warnung',
text: `Eintrag ${stelle + 1} hat Stunden, aber keinen Text.`,
feld: `betrieb.${stelle}`,
});
}
if (eintrag.text.trim().length > 0 && eintrag.text.trim().length < 12) {
warnungen.push({
art: 'floskel',
schwere: 'hinweis',
text: 'Sehr kurzer Eintrag. Schreib dazu, woran genau du gearbeitet hast und was dabei herauskam.',
feld: `betrieb.${stelle}`,
auszug: eintrag.text.trim(),
});
}
}
return warnungen;
}
function pruefeStunden(blatt: Wochenblatt): Warnung[] {
const warnungen: Warnung[] = [];
for (const [stelle, eintrag] of blatt.betrieb.entries()) {
if (eintrag.text.trim() === '') continue;
if (eintrag.stunden <= 0) {
warnungen.push({
art: 'stunden',
schwere: 'warnung',
text: 'Zu diesem Eintrag fehlt die Stundenangabe.',
feld: `betrieb.${stelle}`,
auszug: kuerze(eintrag.text),
});
} else if (eintrag.stunden > REGELSTUNDEN) {
warnungen.push({
art: 'stunden',
schwere: 'warnung',
text: `${zahl(eintrag.stunden)} Stunden für einen einzelnen Eintrag sind mehr als eine ganze Woche.`,
feld: `betrieb.${stelle}`,
auszug: kuerze(eintrag.text),
});
}
}
for (const [stelle, woche] of blatt.schule.entries()) {
if (woche.themen.trim() === '' && woche.lernfeld.trim() === '') continue;
if (woche.stunden <= 0) {
warnungen.push({
art: 'stunden',
schwere: 'hinweis',
text: 'Zum Berufsschulteil fehlt die Stundenangabe.',
feld: `schule.${stelle}`,
});
}
}
const gesamt = summeStunden(blatt);
if (gesamt > 0) {
if (gesamt > REGELSTUNDEN + 20) {
warnungen.push({
art: 'stunden',
schwere: 'warnung',
text: `Zusammen ${zahl(gesamt)} Stunden – das sind mehr als ${REGELSTUNDEN + 20} in einer Woche. Stimmt das?`,
feld: 'summe',
});
} else if (gesamt < REGELSTUNDEN / 2) {
warnungen.push({
art: 'stunden',
schwere: 'hinweis',
text: `Zusammen nur ${zahl(gesamt)} Stunden. Bei einer vollen Woche fehlt vermutlich etwas – Urlaub oder Krankheit gehören unter „Sonstiges".`,
feld: 'summe',
});
}
}
return warnungen;
}
function pruefeFloskeln(blatt: Wochenblatt): Warnung[] {
const warnungen: Warnung[] = [];
for (const [stelle, eintrag] of blatt.betrieb.entries()) {
const text = eintrag.text.trim();
if (text === '') continue;
const treffer = gefundeneFloskel(text);
if (treffer) {
warnungen.push({
art: 'floskel',
schwere: 'warnung',
text: `„${treffer}" sagt nicht, was du gemacht hast. Nenne die Tätigkeit, das Gerät oder den Dienst und das Ergebnis.`,
feld: `betrieb.${stelle}`,
auszug: kuerze(text),
});
}
}
return warnungen;
}
/**
* Findet eine Floskel, die den Eintrag im Wesentlichen ausmacht.
*
* Die Länge ist der Trick: „Diverse Arbeiten" ist eine Floskel, „Diverse
* Patchkabel konfektioniert und beschriftet" nicht. Deshalb schlägt die Prüfung
* nur an, wenn der Eintrag kaum mehr enthält als die Floskel selbst – oder
* wenn er zusätzlich kein einziges Tätigkeitsverb hat.
*/
export function gefundeneFloskel(text: string): string | null {
const normal = normalisiere(text);
for (const floskel of floskeln) {
const stelle = normal.indexOf(floskel);
if (stelle === -1) continue;
const restlaenge = normal.length - floskel.length;
const hatVerb = taetigkeitsverben.some((verb) => normal.includes(normalisiere(verb)));
// Macht die Floskel den Eintrag aus? Oder fehlt sonst jede Tätigkeit?
if (restlaenge <= 20 || !hatVerb) {
return text.slice(stelle, stelle + floskel.length).trim() || floskel;
}
}
return null;
}
function pruefeSchutzwuerdiges(blatt: Wochenblatt): Warnung[] {
const warnungen: Warnung[] = [];
const felder: { feld: string; text: string }[] = [
...blatt.betrieb.map((e, stelle) => ({ feld: `betrieb.${stelle}`, text: e.text })),
...blatt.schule.map((e, stelle) => ({ feld: `schule.${stelle}`, text: e.themen })),
{ feld: 'sonstiges', text: blatt.sonstiges },
];
for (const { feld, text } of felder) {
if (!text.trim()) continue;
for (const fund of findeSchutzwuerdiges(text)) {
warnungen.push({ ...fund, feld });
}
}
return warnungen;
}
export type Fund = { art: 'daten'; schwere: 'warnung' | 'hinweis'; text: string; auszug: string };
/** Sucht Angaben, die nicht ins Berichtsheft gehören. */
export function findeSchutzwuerdiges(text: string): Fund[] {
const funde: Fund[] = [];
const suche = (muster: RegExp, meldung: string, schwere: 'warnung' | 'hinweis' = 'warnung') => {
const gefunden = text.match(new RegExp(muster.source, muster.flags));
if (!gefunden) return;
for (const treffer of [...new Set(gefunden)]) {
funde.push({ art: 'daten', schwere, text: meldung, auszug: treffer.trim() });
}
};
suche(geheimnis, 'Zugangsdaten haben im Berichtsheft nichts verloren. Streich das ersatzlos.');
suche(ipv4, 'Eine IP-Adresse gehört nicht ins Berichtsheft. Schreib „im Kundennetz" oder „im Servernetz".');
suche(macAdresse, 'Eine MAC-Adresse gehört nicht ins Berichtsheft.');
suche(emailAdresse, 'Eine E-Mail-Adresse gehört nicht ins Berichtsheft.');
suche(rechtsform, 'Das sieht nach einem Kundennamen aus. Schreib „bei einem Kunden" oder nenne die Branche.');
suche(telefonnummer, 'Das sieht nach einer Telefonnummer aus.', 'hinweis');
return funde;
}
// --- Hilfsmittel -----------------------------------------------------------
export function summeStunden(blatt: Wochenblatt): number {
const betrieb = blatt.betrieb.reduce((summe, e) => summe + (e.text.trim() ? Math.max(0, e.stunden) : 0), 0);
const schule = blatt.schule.reduce((summe, e) => summe + Math.max(0, e.stunden), 0);
return runde(betrieb + schule);
}
export function summeBetrieb(blatt: Wochenblatt): number {
return runde(blatt.betrieb.reduce((summe, e) => summe + (e.text.trim() ? Math.max(0, e.stunden) : 0), 0));
}
export function summeSchule(blatt: Wochenblatt): number {
return runde(blatt.schule.reduce((summe, e) => summe + Math.max(0, e.stunden), 0));
}
/** Zählt, wie viele Warnungen wirklich ernst sind. */
export function anzahlErnst(warnungen: Warnung[]): number {
return warnungen.filter((w) => w.schwere === 'warnung').length;
}
function normalisiere(text: string): string {
return text
.toLocaleLowerCase('de-DE')
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9 ]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function kuerze(text: string, laenge = 70): string {
const sauber = text.trim().replace(/\s+/g, ' ');
return sauber.length <= laenge ? sauber : `${sauber.slice(0, laenge - 1)}…`;
}
function zahl(wert: number): string {
return runde(wert).toString().replace('.', ',');
}
function runde(wert: number): number {
return Math.round(wert * 100) / 100;
}
+159
View File
@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import { baueVorschlaege, stundenAus, type Rohdaten } from './vorschlaege';
function daten(teil: Partial<Rohdaten> = {}): Rohdaten {
return {
tagesnotizen: [],
aufgaben: [],
checklisten: [],
handouts: [],
lernfelder: [],
...teil,
};
}
describe('Vorschläge aus Tagesnotizen', () => {
it('macht aus jeder Notiz einen Vorschlag', () => {
const vorschlaege = baueVorschlaege(
daten({
tagesnotizen: [
{ datum: '2026-09-07', text: 'Drei Rechner aufgesetzt.', stunden: 6 },
{ datum: '2026-09-08', text: 'Tickets bearbeitet.', stunden: 4 },
],
}),
);
expect(vorschlaege).toHaveLength(2);
expect(vorschlaege[0]!.text).toBe('Drei Rechner aufgesetzt.');
expect(vorschlaege[0]!.stunden).toBe(6);
expect(vorschlaege[0]!.ziel).toBe('betrieb');
});
it('nennt den Wochentag als Herkunft', () => {
const vorschlaege = baueVorschlaege(
daten({ tagesnotizen: [{ datum: '2026-09-07', text: 'Etwas gemacht.', stunden: 8 }] }),
);
expect(vorschlaege[0]!.herkunft).toContain('Montag');
});
it('überspringt leere Notizen', () => {
const vorschlaege = baueVorschlaege(
daten({ tagesnotizen: [{ datum: '2026-09-07', text: ' ', stunden: 8 }] }),
);
expect(vorschlaege).toHaveLength(0);
});
});
describe('Vorschläge aus Praxisaufgaben', () => {
it('formuliert eine abgenommene Aufgabe anders als eine abgegebene', () => {
const abgenommen = baueVorschlaege(
daten({ aufgaben: [{ id: '1.1', titel: 'Arbeitsplatz übernehmen', zeitrahmen: '3 Stunden', status: 'abgenommen' }] }),
);
const abgegeben = baueVorschlaege(
daten({ aufgaben: [{ id: '1.1', titel: 'Arbeitsplatz übernehmen', zeitrahmen: '3 Stunden', status: 'zur_abnahme' }] }),
);
expect(abgenommen[0]!.text).toContain('abgeschlossen und abgenommen');
expect(abgegeben[0]!.text).toContain('zur Abnahme vorgelegt');
});
it('übernimmt den Zeitrahmen als Anhaltspunkt', () => {
const vorschlaege = baueVorschlaege(
daten({ aufgaben: [{ id: '2.2', titel: 'Notebooks vergleichen', zeitrahmen: '4 Stunden', status: 'abgenommen' }] }),
);
expect(vorschlaege[0]!.stunden).toBe(4);
});
});
describe('Vorschläge aus Checklisten', () => {
it('nennt Ticket und Kunde, wenn vorhanden', () => {
const vorschlaege = baueVorschlaege(
daten({
checklisten: [{ id: 'x', titel: 'Windows 11 installieren', ticket: '4711', kunde: 'Buchhaltung' }],
}),
);
expect(vorschlaege[0]!.text).toContain('(Ticket 4711, Buchhaltung)');
});
it('lässt die Klammer weg, wenn es nichts zu nennen gibt', () => {
const vorschlaege = baueVorschlaege(
daten({ checklisten: [{ id: 'x', titel: 'Rechner zerlegen', ticket: null, kunde: null }] }),
);
expect(vorschlaege[0]!.text).not.toContain('(');
});
});
describe('Vorschläge aus Handouts', () => {
it('fasst alle gelesenen Handouts zu einer Unterweisung zusammen', () => {
const vorschlaege = baueVorschlaege(
daten({
handouts: [
{ id: 'N1', titel: 'Das Schichtenmodell' },
{ id: 'N2', titel: 'IP-Adresse und Subnetzmaske' },
],
}),
);
expect(vorschlaege).toHaveLength(1);
expect(vorschlaege[0]!.text).toContain('N1, N2');
expect(vorschlaege[0]!.text).toContain('Unterweisung');
});
it('macht ohne gelesene Handouts keinen Vorschlag', () => {
expect(baueVorschlaege(daten({ handouts: [] }))).toHaveLength(0);
});
});
describe('Vorschläge für die Berufsschule', () => {
it('landen im Schulteil, nicht im Betriebsteil', () => {
const vorschlaege = baueVorschlaege(
daten({ lernfelder: [{ id: 'LF3', titel: 'Clients in Netzwerke einbinden' }] }),
);
expect(vorschlaege[0]!.ziel).toBe('schule');
expect(vorschlaege[0]!.herkunft).toBe('LF3');
});
});
describe('Eindeutige Kennungen', () => {
it('vergibt jedem Vorschlag eine eigene Kennung', () => {
const vorschlaege = baueVorschlaege(
daten({
tagesnotizen: [
{ datum: '2026-09-07', text: 'A', stunden: 1 },
{ datum: '2026-09-08', text: 'B', stunden: 1 },
],
aufgaben: [{ id: '1.1', titel: 'X', zeitrahmen: '2 Stunden', status: 'abgenommen' }],
checklisten: [{ id: 'c1', titel: 'Y', ticket: null, kunde: null }],
handouts: [{ id: 'N1', titel: 'Z' }],
lernfelder: [{ id: 'LF3', titel: 'W' }],
}),
);
const ids = vorschlaege.map((v) => v.id);
expect(new Set(ids).size).toBe(ids.length);
expect(vorschlaege).toHaveLength(6);
});
});
describe('Stunden aus einer Zeitangabe lesen', () => {
it('versteht Stundenangaben', () => {
expect(stundenAus('3 Stunden')).toBe(3);
expect(stundenAus('ca. 4 Std.')).toBe(4);
expect(stundenAus('2h')).toBe(2);
});
it('rechnet Minuten in Stunden um, auf Viertelstunden gerundet', () => {
expect(stundenAus('90 Minuten')).toBe(1.5);
expect(stundenAus('45 min')).toBe(0.75);
expect(stundenAus('100 Minuten')).toBe(1.75);
});
it('gibt 0 zurück, wenn nichts zu erkennen ist', () => {
expect(stundenAus('nach Aufwand')).toBe(0);
expect(stundenAus('')).toBe(0);
});
it('bevorzugt Stunden vor Minuten, wenn beides dasteht', () => {
expect(stundenAus('3 Stunden 30 Minuten')).toBe(3);
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Vorschläge für das Wochenblatt.
*
* Der Sinn: Freitags soll niemand vor einem leeren Blatt sitzen und
* rekonstruieren, was Dienstag war. Die Anwendung weiß das schon – aus den
* Tagesnotizen, den abgegebenen Praxisaufgaben, den abgeschlossenen
* Checklisten-Durchläufen und den gelesenen Handouts.
*
* Wichtig: Ein Vorschlag ist ein **Vorschlag**. Er wird übernommen und dann
* bearbeitet. Ein wörtlich übernommener Satz aus der Maschine ist kein
* Ausbildungsnachweis, und die Stundenangabe ist immer nur ein Anhaltspunkt.
*/
export type Quelle = 'tagesnotiz' | 'aufgabe' | 'checkliste' | 'handout';
export type Vorschlag = {
/** Eindeutig innerhalb einer Vorschlagsliste, damit die Oberfläche merkt, was übernommen wurde. */
id: string;
quelle: Quelle;
ziel: 'betrieb' | 'schule';
text: string;
stunden: number;
/** Woher der Vorschlag stammt, für die Anzeige. */
herkunft: string;
};
export type Rohdaten = {
tagesnotizen: { datum: string; text: string; stunden: number }[];
aufgaben: { id: string; titel: string; zeitrahmen: string; status: string }[];
checklisten: { id: string; titel: string; ticket: string | null; kunde: string | null }[];
handouts: { id: string; titel: string }[];
/** Berufsschulthemen der Woche, aus dem Wochenplan. */
lernfelder: { id: string; titel: string }[];
};
const wochentage = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
export function baueVorschlaege(daten: Rohdaten): Vorschlag[] {
const vorschlaege: Vorschlag[] = [];
// --- Tagesnotizen: der wertvollste Rohstoff, weil selbst geschrieben ---
for (const notiz of daten.tagesnotizen) {
const text = notiz.text.trim();
if (!text) continue;
vorschlaege.push({
id: `tagesnotiz:${notiz.datum}`,
quelle: 'tagesnotiz',
ziel: 'betrieb',
text,
stunden: notiz.stunden > 0 ? notiz.stunden : 0,
herkunft: `${wochentagVon(notiz.datum)}, ${notiz.datum}`,
});
}
// --- Praxisaufgaben ---
for (const aufgabe of daten.aufgaben) {
const wort = aufgabe.status === 'abgenommen' ? 'abgeschlossen und abgenommen' : 'bearbeitet und zur Abnahme vorgelegt';
vorschlaege.push({
id: `aufgabe:${aufgabe.id}`,
quelle: 'aufgabe',
ziel: 'betrieb',
text: `Praxisaufgabe ${aufgabe.id} „${aufgabe.titel}" ${wort}.`,
stunden: stundenAus(aufgabe.zeitrahmen),
herkunft: `Praxisaufgabe ${aufgabe.id}`,
});
}
// --- Checklisten-Durchläufe ---
for (const lauf of daten.checklisten) {
const zusatz = [lauf.ticket ? `Ticket ${lauf.ticket}` : null, lauf.kunde].filter(Boolean).join(', ');
vorschlaege.push({
id: `checkliste:${lauf.id}`,
quelle: 'checkliste',
ziel: 'betrieb',
text: `„${lauf.titel}" vollständig nach Checkliste abgearbeitet${zusatz ? ` (${zusatz})` : ''}.`,
stunden: 0,
herkunft: 'Checkliste',
});
}
// --- Gelesene Handouts: gehören zur Unterweisung, nicht zur Tätigkeit ---
if (daten.handouts.length > 0) {
const kuerzel = daten.handouts.map((h) => h.id).join(', ');
const titel = daten.handouts.map((h) => `„${h.titel}"`).join(', ');
vorschlaege.push({
id: `handouts:${kuerzel}`,
quelle: 'handout',
ziel: 'betrieb',
text: `Unterweisung: Lerninhalte ${kuerzel} durchgearbeitet – ${titel}.`,
stunden: 0,
herkunft: `${daten.handouts.length} gelesene Lerninhalte`,
});
}
// --- Berufsschule ---
for (const lernfeld of daten.lernfelder) {
vorschlaege.push({
id: `lernfeld:${lernfeld.id}`,
quelle: 'handout',
ziel: 'schule',
text: lernfeld.titel,
stunden: 0,
herkunft: lernfeld.id,
});
}
return vorschlaege;
}
/**
* Liest eine Stundenzahl aus einer Zeitangabe wie „3 Stunden" oder „90 Minuten".
* Ein Anhaltspunkt, kein Messwert – der Azubi trägt die echte Zeit ein.
*/
export function stundenAus(zeitrahmen: string): number {
const stunden = /(\d+(?:[.,]\d+)?)\s*(?:stunde|stunden|std|h)\b/i.exec(zeitrahmen);
if (stunden) return runde(Number(stunden[1]!.replace(',', '.')));
const minuten = /(\d+(?:[.,]\d+)?)\s*(?:minute|minuten|min)\b/i.exec(zeitrahmen);
if (minuten) return runde(Number(minuten[1]!.replace(',', '.')) / 60);
return 0;
}
function wochentagVon(datum: string): string {
const tag = new Date(`${datum}T12:00:00Z`).getUTCDay();
return wochentage[tag] ?? '';
}
function runde(wert: number): number {
return Math.round(wert * 4) / 4; // auf Viertelstunden
}
@@ -0,0 +1,42 @@
-- Baustein 7: Ausbildungsnachweis („Berichtsheft").
--
-- Der gesetzlich vorgeschriebene Teil. Ohne geführten Nachweis keine Zulassung
-- zur Abschlussprüfung – deshalb ist hier alles auf Nachvollziehbarkeit
-- ausgelegt: abgezeichnete Blätter sind gesperrt, und der Abzeichnende wird
-- mit Zeitstempel festgehalten.
CREATE TABLE tagesnotiz (
id TEXT PRIMARY KEY,
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
datum TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
stunden REAL NOT NULL DEFAULT 0,
geaendert_am TEXT NOT NULL
);
CREATE UNIQUE INDEX tagesnotiz_tag_idx ON tagesnotiz (benutzer_id, datum);
CREATE TABLE wochenbericht (
id TEXT PRIMARY KEY,
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
-- Fortlaufende Nummer des Ausbildungsnachweises, wie auf dem Papierformular.
nr INTEGER NOT NULL,
von TEXT NOT NULL,
bis TEXT NOT NULL,
-- Tätigkeiten und Berufsschule als JSON-Listen. Sie gehören immer als Ganzes
-- zum Blatt, werden nie einzeln abgefragt und nie einzeln sortiert.
betrieb TEXT NOT NULL DEFAULT '[]',
schule TEXT NOT NULL DEFAULT '[]',
sonstiges TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'entwurf'
CHECK (status IN ('entwurf', 'eingereicht', 'zurueckgegeben', 'abgezeichnet')),
anmerkung TEXT,
eingereicht_am TEXT,
abgezeichnet_von TEXT REFERENCES benutzer (id),
abgezeichnet_am TEXT,
geaendert_am TEXT NOT NULL
);
CREATE UNIQUE INDEX wochenbericht_nr_idx ON wochenbericht (benutzer_id, nr);
CREATE INDEX wochenbericht_zeitraum_idx ON wochenbericht (benutzer_id, von);
CREATE INDEX wochenbericht_status_idx ON wochenbericht (status);
+49
View File
@@ -217,6 +217,51 @@ export const checklisteSchritt = sqliteTable(
],
);
export const berichtStatus = ['entwurf', 'eingereicht', 'zurueckgegeben', 'abgezeichnet'] as const;
export type BerichtStatus = (typeof berichtStatus)[number];
export const tagesnotiz = sqliteTable(
'tagesnotiz',
{
id: text('id').primaryKey(),
benutzerId: text('benutzer_id')
.notNull()
.references(() => benutzer.id, { onDelete: 'cascade' }),
datum: text('datum').notNull(),
text: text('text').notNull().default(''),
stunden: real('stunden').notNull().default(0),
geaendertAm: text('geaendert_am').notNull(),
},
(t) => [uniqueIndex('tagesnotiz_tag_idx').on(t.benutzerId, t.datum)],
);
export const wochenbericht = sqliteTable(
'wochenbericht',
{
id: text('id').primaryKey(),
benutzerId: text('benutzer_id')
.notNull()
.references(() => benutzer.id, { onDelete: 'cascade' }),
nr: integer('nr').notNull(),
von: text('von').notNull(),
bis: text('bis').notNull(),
betrieb: text('betrieb').notNull().default('[]'),
schule: text('schule').notNull().default('[]'),
sonstiges: text('sonstiges').notNull().default(''),
status: text('status', { enum: berichtStatus }).notNull().default('entwurf'),
anmerkung: text('anmerkung'),
eingereichtAm: text('eingereicht_am'),
abgezeichnetVon: text('abgezeichnet_von').references(() => benutzer.id),
abgezeichnetAm: text('abgezeichnet_am'),
geaendertAm: text('geaendert_am').notNull(),
},
(t) => [
uniqueIndex('wochenbericht_nr_idx').on(t.benutzerId, t.nr),
index('wochenbericht_zeitraum_idx').on(t.benutzerId, t.von),
index('wochenbericht_status_idx').on(t.status),
],
);
export type Benutzer = typeof benutzer.$inferSelect;
export type Sitzung = typeof sitzung.$inferSelect;
export type Ausbildung = typeof ausbildung.$inferSelect;
@@ -228,6 +273,8 @@ export type AufgabeStatus = typeof aufgabeStatus.$inferSelect;
export type Anhang = typeof anhang.$inferSelect;
export type ChecklisteLauf = typeof checklisteLauf.$inferSelect;
export type ChecklisteSchritt = typeof checklisteSchritt.$inferSelect;
export type Tagesnotiz = typeof tagesnotiz.$inferSelect;
export type Wochenbericht = typeof wochenbericht.$inferSelect;
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
export const alleTabellen = {
@@ -242,4 +289,6 @@ export const alleTabellen = {
anhang,
checklisteLauf,
checklisteSchritt,
tagesnotiz,
wochenbericht,
};
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { berichtsheftPdf, type Blattangaben } from './berichtsheft';
import type { Wochenblatt } from '../berichtsheft/pruefung';
function seitenzahl(pdf: Buffer): number {
return (pdf.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []).length;
}
function blatt(teil: Partial<Wochenblatt> = {}): Wochenblatt {
return {
betrieb: [
{ text: 'Vier Arbeitsplatzrechner nach Checkliste neu aufgesetzt und ins Netz gebracht.', stunden: 12 },
{ text: 'Störungsmeldungen im Ticketsystem bearbeitet, zwei Druckerprobleme behoben.', stunden: 10 },
],
schule: [{ lernfeld: 'LF3', themen: 'Subnetting, Adressvergabe über DHCP', stunden: 8 }],
sonstiges: 'Montag Betriebsversammlung, eine Stunde.',
...teil,
};
}
function angaben(nr: number, teil: Partial<Blattangaben> = {}): Blattangaben {
return {
nr,
von: '07.09.2026',
bis: '11.09.2026',
azubi: 'Jonas Berger',
ausbilder: 'Florian Wilde',
beruf: 'Fachinformatiker/-in für Systemintegration',
ausbildungsjahr: 1,
status: 'abgezeichnet',
anmerkung: null,
abgezeichnetVon: 'Florian Wilde',
abgezeichnetAm: '12.09.2026, 08:10',
blatt: blatt(),
...teil,
};
}
describe('Berichtsheft-PDF', () => {
it('erzeugt ein gültiges PDF', async () => {
const pdf = await berichtsheftPdf([angaben(1)]);
expect(pdf.subarray(0, 5).toString('latin1')).toBe('%PDF-');
expect(pdf.byteLength).toBeGreaterThan(1000);
});
it('setzt ein Wochenblatt auf genau eine Seite', async () => {
const pdf = await berichtsheftPdf([angaben(1)]);
expect(seitenzahl(pdf)).toBe(1);
});
it('gibt jedem Blatt eine eigene Seite – für den Zeitraumexport', async () => {
const pdf = await berichtsheftPdf([angaben(1), angaben(2), angaben(3), angaben(4)]);
expect(seitenzahl(pdf)).toBe(4);
});
it('bleibt auch bei vielen Einträgen bei einer Seite je Blatt', async () => {
const viele = blatt({
betrieb: Array.from({ length: 8 }, (_, i) => ({
text: `Tätigkeit ${i + 1}: eine ausführlich beschriebene Arbeit mit Gerät, Vorgehen und Ergebnis.`,
stunden: 2,
})),
});
const pdf = await berichtsheftPdf([angaben(1, { blatt: viele })]);
expect(seitenzahl(pdf)).toBeLessThanOrEqual(2);
});
it('kommt mit einem leeren Blatt zurecht', async () => {
const leer = blatt({ betrieb: [], schule: [], sonstiges: '' });
const pdf = await berichtsheftPdf([angaben(1, { blatt: leer })]);
expect(seitenzahl(pdf)).toBe(1);
});
it('kommt ohne Blätter zurecht, statt zu scheitern', async () => {
const pdf = await berichtsheftPdf([]);
expect(pdf.subarray(0, 5).toString('latin1')).toBe('%PDF-');
expect(seitenzahl(pdf)).toBe(1);
});
it('verkraftet ein noch nicht abgezeichnetes Blatt', async () => {
const pdf = await berichtsheftPdf([
angaben(1, { status: 'entwurf', abgezeichnetVon: null, abgezeichnetAm: null }),
]);
expect(seitenzahl(pdf)).toBe(1);
});
it('nimmt eine Anmerkung des Ausbilders auf', async () => {
const pdf = await berichtsheftPdf([
angaben(1, { anmerkung: 'Bitte beim nächsten Blatt die Stunden je Tätigkeit aufschlüsseln.' }),
]);
expect(seitenzahl(pdf)).toBe(1);
expect(pdf.byteLength).toBeGreaterThan(1000);
});
it('verkraftet sehr lange Einzeltexte ohne Absturz', async () => {
const lang = blatt({
betrieb: [{ text: 'Eine sehr ausführliche Beschreibung. '.repeat(50), stunden: 20 }],
sonstiges: 'Auch hier steht viel. '.repeat(40),
});
const pdf = await berichtsheftPdf([angaben(1, { blatt: lang })]);
expect(pdf.byteLength).toBeGreaterThan(1000);
});
});
+338
View File
@@ -0,0 +1,338 @@
import {
alsPuffer,
FARBEN,
fuerPdf,
fusszeilen,
INHALTSBREITE,
neuesDokument,
SEITE,
type Dokument,
} from './grundlage';
import type { Wochenblatt } from '../berichtsheft/pruefung';
import { summeBetrieb, summeSchule, summeStunden } from '../berichtsheft/pruefung';
/**
* Ausbildungsnachweis im Layout eines üblichen IHK-Wochenblatts.
*
* Absichtlich nah am Papierformular: Kopffeld mit Name, Ausbildungsjahr und
* Berichtszeitraum, ein Kasten für betriebliche Tätigkeiten, einer für die
* Berufsschule, einer für Sonstiges, darunter die Unterschriftenzeilen.
* Jedes Blatt beginnt auf einer neuen Seite, damit ein Zeitraum gebündelt
* ausgedruckt und abgeheftet werden kann.
*/
export type Blattangaben = {
nr: number;
von: string;
bis: string;
azubi: string;
ausbilder: string;
beruf: string;
ausbildungsjahr: number | null;
status: string;
anmerkung: string | null;
abgezeichnetVon: string | null;
abgezeichnetAm: string | null;
blatt: Wochenblatt;
};
const STUNDENSPALTE = 58;
export async function berichtsheftPdf(blaetter: Blattangaben[], titelzusatz?: string): Promise<Buffer> {
const erstes = blaetter[0];
const dokument = neuesDokument(
blaetter.length === 1 && erstes ? `Ausbildungsnachweis Nr. ${erstes.nr}` : 'Ausbildungsnachweis',
erstes?.azubi ?? 'Lern-Bar',
);
blaetter.forEach((angaben, stelle) => {
if (stelle > 0) dokument.addPage();
zeichneBlatt(dokument, angaben);
});
if (blaetter.length === 0) {
dokument.font('Helvetica').fontSize(11).text('Für diesen Zeitraum gibt es keine Wochenblätter.', SEITE.rand, 120);
}
fusszeilen(dokument, fuerPdf(`Ausbildungsnachweis${titelzusatz ? ` - ${titelzusatz}` : ''} - Lern-Bar`));
return alsPuffer(dokument);
}
function zeichneBlatt(dokument: Dokument, angaben: Blattangaben): void {
const { blatt } = angaben;
// --- Kopf ---
dokument
.font('Helvetica-Bold')
.fontSize(15)
.fillColor(FARBEN.text)
.text('Ausbildungsnachweis', SEITE.rand, SEITE.rand, { width: INHALTSBREITE - 120 });
dokument
.font('Helvetica-Bold')
.fontSize(12)
.text(`Nr. ${angaben.nr}`, SEITE.breite - SEITE.rand - 120, SEITE.rand + 2, {
width: 120,
align: 'right',
});
dokument.x = SEITE.rand;
dokument.y = SEITE.rand + 22;
kopffeld(dokument, [
['Name des Auszubildenden', angaben.azubi],
['Ausbildungsberuf', angaben.beruf],
['Ausbildungsjahr', angaben.ausbildungsjahr ? `${angaben.ausbildungsjahr}. Ausbildungsjahr` : '–'],
['Berichtszeitraum', `${angaben.von} bis ${angaben.bis}`],
]);
dokument.y += 10;
// --- Betriebliche Tätigkeiten ---
const betrieb = blatt.betrieb.filter((e) => e.text.trim() !== '');
kasten(
dokument,
'Betriebliche Tätigkeiten und Unterweisungen',
betrieb.map((e) => ({ text: e.text, stunden: e.stunden })),
summeBetrieb(blatt),
betrieb.length === 0 ? 'Keine Eintragung.' : undefined,
);
// --- Berufsschule ---
const schule = blatt.schule.filter((e) => e.themen.trim() !== '' || e.lernfeld.trim() !== '');
kasten(
dokument,
'Berufsschule (Lernfeld und Themen)',
schule.map((e) => ({
text: e.lernfeld.trim() ? `${e.lernfeld} – ${e.themen}` : e.themen,
stunden: e.stunden,
})),
summeSchule(blatt),
schule.length === 0 ? 'Keine Eintragung.' : undefined,
);
// --- Sonstiges ---
kasten(dokument, 'Sonstiges', [], null, blatt.sonstiges.trim() || 'Keine Eintragung.');
// --- Gesamtsumme ---
dokument.y += 4;
const summeY = dokument.y;
dokument
.font('Helvetica-Bold')
.fontSize(10)
.fillColor(FARBEN.text)
.text('Gesamtstunden der Woche', SEITE.rand, summeY, { width: INHALTSBREITE - STUNDENSPALTE });
dokument
.font('Helvetica-Bold')
.fontSize(10)
.text(stundenText(summeStunden(blatt)), SEITE.breite - SEITE.rand - STUNDENSPALTE, summeY, {
width: STUNDENSPALTE,
align: 'right',
});
dokument.x = SEITE.rand;
dokument.y = summeY + 16;
// --- Anmerkung des Ausbilders ---
if (angaben.anmerkung) {
dokument.y += 4;
const y = dokument.y;
dokument
.rect(SEITE.rand, y, INHALTSBREITE, 0)
.fillColor(FARBEN.text);
dokument.font('Helvetica-Bold').fontSize(9).fillColor(FARBEN.leise).text('Anmerkung des Ausbilders', SEITE.rand, y);
dokument
.font('Helvetica')
.fontSize(10)
.fillColor(FARBEN.text)
.text(fuerPdf(angaben.anmerkung), SEITE.rand, dokument.y + 1, { width: INHALTSBREITE });
dokument.x = SEITE.rand;
dokument.y += 6;
}
// --- Unterschriften ---
unterschriftenblock(dokument, angaben);
}
function kopffeld(dokument: Dokument, zeilen: [string, string][]): void {
const spalte = INHALTSBREITE / 2 - 8;
// Der Bezugspunkt wird **vor** der Schleife festgehalten: `dokument.y` wandert
// bei jedem text()-Aufruf weiter, und die Zeilen liefen sonst auseinander.
const obenY = dokument.y;
zeilen.forEach(([beschriftung, wert], stelle) => {
const spaltenNr = stelle % 2;
const zeilenNr = Math.floor(stelle / 2);
const x = SEITE.rand + spaltenNr * (spalte + 16);
const y = obenY + zeilenNr * 30;
dokument.font('Helvetica').fontSize(8).fillColor(FARBEN.leise).text(beschriftung, x, y, { width: spalte });
dokument
.font('Helvetica')
.fontSize(10.5)
.fillColor(FARBEN.text)
.text(fuerPdf(wert), x, y + 10, { width: spalte, lineBreak: false, ellipsis: true });
dokument
.moveTo(x, y + 25)
.lineTo(x + spalte, y + 25)
.lineWidth(0.5)
.strokeColor(FARBEN.linie)
.stroke();
});
dokument.x = SEITE.rand;
dokument.y = obenY + Math.ceil(zeilen.length / 2) * 30;
}
/**
* Ein Kasten mit Überschriftszeile, Einträgen und Stundenspalte.
* Die Höhe richtet sich nach dem Inhalt, mindestens aber nach dem Platz, den
* ein Papierformular vorsieht – ein halb leerer Kasten sieht richtiger aus als
* eine zusammengeschrumpfte Zeile.
*/
function kasten(
dokument: Dokument,
ueberschrift: string,
eintraege: { text: string; stunden: number }[],
summe: number | null,
ersatztext?: string,
): void {
const obenY = dokument.y;
const textBreite = INHALTSBREITE - STUNDENSPALTE - 16;
// Höhe vorausberechnen, damit der Rahmen stimmt.
dokument.font('Helvetica').fontSize(10);
let inhaltshoehe = 0;
for (const eintrag of eintraege) {
inhaltshoehe += dokument.heightOfString(fuerPdf(eintrag.text), { width: textBreite }) + 4;
}
if (ersatztext) {
inhaltshoehe += dokument.heightOfString(fuerPdf(ersatztext), { width: textBreite }) + 4;
}
const kopfhoehe = 18;
const summenhoehe = summe !== null ? 16 : 0;
const hoehe = Math.max(58, kopfhoehe + inhaltshoehe + summenhoehe + 8);
// Rahmen und Kopfleiste
dokument.rect(SEITE.rand, obenY, INHALTSBREITE, hoehe).lineWidth(0.75).strokeColor(FARBEN.linie).stroke();
dokument.rect(SEITE.rand, obenY, INHALTSBREITE, kopfhoehe).fillColor(FARBEN.flaeche).fill();
dokument
.moveTo(SEITE.rand, obenY + kopfhoehe)
.lineTo(SEITE.breite - SEITE.rand, obenY + kopfhoehe)
.lineWidth(0.75)
.strokeColor(FARBEN.linie)
.stroke();
dokument
.font('Helvetica-Bold')
.fontSize(9.5)
.fillColor(FARBEN.petrol)
.text(fuerPdf(ueberschrift), SEITE.rand + 8, obenY + 5, { width: textBreite });
if (summe !== null) {
dokument
.font('Helvetica-Bold')
.fontSize(8.5)
.fillColor(FARBEN.leise)
.text('Stunden', SEITE.breite - SEITE.rand - STUNDENSPALTE - 8, obenY + 6, {
width: STUNDENSPALTE,
align: 'right',
});
}
// Einträge
let y = obenY + kopfhoehe + 6;
dokument.fillColor(FARBEN.text);
for (const eintrag of eintraege) {
dokument.font('Helvetica').fontSize(10);
const zeilenhoehe = dokument.heightOfString(fuerPdf(eintrag.text), { width: textBreite });
dokument.text(fuerPdf(eintrag.text), SEITE.rand + 8, y, { width: textBreite });
dokument
.font('Helvetica')
.fontSize(10)
.text(eintrag.stunden > 0 ? stundenText(eintrag.stunden) : '–', SEITE.breite - SEITE.rand - STUNDENSPALTE - 8, y, {
width: STUNDENSPALTE,
align: 'right',
});
y += zeilenhoehe + 4;
}
if (ersatztext) {
dokument
.font(eintraege.length === 0 && ersatztext.startsWith('Keine') ? 'Helvetica-Oblique' : 'Helvetica')
.fontSize(10)
.fillColor(eintraege.length === 0 && ersatztext.startsWith('Keine') ? FARBEN.leise : FARBEN.text)
.text(fuerPdf(ersatztext), SEITE.rand + 8, y, { width: textBreite });
}
// Zwischensumme
if (summe !== null) {
const summeY = obenY + hoehe - 14;
dokument
.moveTo(SEITE.breite - SEITE.rand - STUNDENSPALTE - 16, summeY - 3)
.lineTo(SEITE.breite - SEITE.rand - 8, summeY - 3)
.lineWidth(0.5)
.strokeColor(FARBEN.linie)
.stroke();
dokument
.font('Helvetica-Bold')
.fontSize(9)
.fillColor(FARBEN.text)
.text(stundenText(summe), SEITE.breite - SEITE.rand - STUNDENSPALTE - 8, summeY, {
width: STUNDENSPALTE,
align: 'right',
});
}
dokument.x = SEITE.rand;
dokument.y = obenY + hoehe + 8;
dokument.fillColor(FARBEN.text);
}
function unterschriftenblock(dokument: Dokument, angaben: Blattangaben): void {
// Der Block sitzt am Fuß der Seite, wie auf dem Papierformular.
const y = Math.max(dokument.y + 16, SEITE.hoehe - SEITE.rand - 96);
const spalte = (INHALTSBREITE - 24) / 2;
const felder: [string, string][] = [
['Datum, Unterschrift Auszubildender', ''],
[
'Datum, Unterschrift Ausbilder',
angaben.abgezeichnetAm && angaben.abgezeichnetVon
? `In der Lern-Bar abgezeichnet am ${angaben.abgezeichnetAm} von ${angaben.abgezeichnetVon}`
: '',
],
];
felder.forEach(([beschriftung, vermerk], stelle) => {
const x = SEITE.rand + stelle * (spalte + 24);
if (vermerk) {
dokument
.font('Helvetica-Oblique')
.fontSize(7.5)
.fillColor(FARBEN.gut)
.text(fuerPdf(vermerk), x, y - 12, { width: spalte });
}
dokument.moveTo(x, y + 14).lineTo(x + spalte, y + 14).lineWidth(0.75).strokeColor(FARBEN.text).stroke();
dokument
.font('Helvetica')
.fontSize(8.5)
.fillColor(FARBEN.leise)
.text(fuerPdf(beschriftung), x, y + 18, { width: spalte });
});
dokument.x = SEITE.rand;
dokument.y = y + 34;
dokument.fillColor(FARBEN.text);
}
function stundenText(wert: number): string {
const gerundet = Math.round(wert * 100) / 100;
return `${gerundet.toString().replace('.', ',')} h`;
}
+250
View File
@@ -0,0 +1,250 @@
import crypto from 'node:crypto';
import { and, asc, desc, eq, gte, lte } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { freitagDerWoche, heute, jetzt, montagDerWoche } from '../zeit';
import type { BerichtStatus, Tagesnotiz, Wochenbericht } from '../db/schema';
import type { Eintrag, Schulwoche, Wochenblatt } from '../berichtsheft/pruefung';
/** Tagesnotizen und Wochenblätter des Ausbildungsnachweises. */
// --- Tagesnotizen ----------------------------------------------------------
export function notizenIm(benutzerId: string, von: string, bis: string): Tagesnotiz[] {
return datenbank()
.select()
.from(schema.tagesnotiz)
.where(
and(
eq(schema.tagesnotiz.benutzerId, benutzerId),
gte(schema.tagesnotiz.datum, von),
lte(schema.tagesnotiz.datum, bis),
),
)
.orderBy(asc(schema.tagesnotiz.datum))
.all();
}
export function notizAm(benutzerId: string, datum: string): Tagesnotiz | null {
return (
datenbank()
.select()
.from(schema.tagesnotiz)
.where(and(eq(schema.tagesnotiz.benutzerId, benutzerId), eq(schema.tagesnotiz.datum, datum)))
.get() ?? null
);
}
/** Schreibt die Notiz eines Tages. Leerer Text mit 0 Stunden löscht sie wieder. */
export function notizSetzen(benutzerId: string, datum: string, text: string, stunden: number): void {
const db = datenbank();
const sauber = text.trim();
if (sauber === '' && stunden <= 0) {
db.delete(schema.tagesnotiz)
.where(and(eq(schema.tagesnotiz.benutzerId, benutzerId), eq(schema.tagesnotiz.datum, datum)))
.run();
return;
}
db.insert(schema.tagesnotiz)
.values({
id: crypto.randomUUID(),
benutzerId,
datum,
text: sauber,
stunden: Math.max(0, stunden),
geaendertAm: jetzt(),
})
.onConflictDoUpdate({
target: [schema.tagesnotiz.benutzerId, schema.tagesnotiz.datum],
set: { text: sauber, stunden: Math.max(0, stunden), geaendertAm: jetzt() },
})
.run();
}
// --- Wochenblätter ---------------------------------------------------------
export function berichteVon(benutzerId: string): Wochenbericht[] {
return datenbank()
.select()
.from(schema.wochenbericht)
.where(eq(schema.wochenbericht.benutzerId, benutzerId))
.orderBy(desc(schema.wochenbericht.nr))
.all();
}
export function berichtNachId(id: string): Wochenbericht | null {
return datenbank().select().from(schema.wochenbericht).where(eq(schema.wochenbericht.id, id)).get() ?? null;
}
export function berichtNachNr(benutzerId: string, nr: number): Wochenbericht | null {
return (
datenbank()
.select()
.from(schema.wochenbericht)
.where(and(eq(schema.wochenbericht.benutzerId, benutzerId), eq(schema.wochenbericht.nr, nr)))
.get() ?? null
);
}
export function berichtImZeitraum(benutzerId: string, von: string): Wochenbericht | null {
return (
datenbank()
.select()
.from(schema.wochenbericht)
.where(and(eq(schema.wochenbericht.benutzerId, benutzerId), eq(schema.wochenbericht.von, von)))
.get() ?? null
);
}
/**
* Legt bei Bedarf das Blatt für die Woche eines Datums an.
*
* Die laufende Nummer ergibt sich aus der Reihenfolge der Zeiträume, nicht aus
* der Reihenfolge des Anlegens: Wer eine Woche nachträgt, soll nicht die
* höchste Nummer bekommen.
*/
export function sorgeFuerBlatt(benutzerId: string, datumInDerWoche: string): Wochenbericht {
const von = montagDerWoche(datumInDerWoche);
const vorhanden = berichtImZeitraum(benutzerId, von);
if (vorhanden) return vorhanden;
const bis = freitagDerWoche(datumInDerWoche);
const alle = berichteVon(benutzerId);
const frueher = alle.filter((b) => b.von < von).length;
const db = datenbank();
// Alles, was zeitlich dahinter liegt, rückt eine Nummer weiter.
for (const bericht of alle.filter((b) => b.von > von)) {
db.update(schema.wochenbericht)
.set({ nr: bericht.nr + 1 })
.where(eq(schema.wochenbericht.id, bericht.id))
.run();
}
const datensatz: Wochenbericht = {
id: crypto.randomUUID(),
benutzerId,
nr: frueher + 1,
von,
bis,
betrieb: '[]',
schule: '[]',
sonstiges: '',
status: 'entwurf',
anmerkung: null,
eingereichtAm: null,
abgezeichnetVon: null,
abgezeichnetAm: null,
geaendertAm: jetzt(),
};
db.insert(schema.wochenbericht).values(datensatz).run();
return datensatz;
}
/** Der Inhalt eines Blattes, aus JSON gelesen und gegen Unsinn abgesichert. */
export function alsWochenblatt(bericht: Wochenbericht): Wochenblatt {
return {
betrieb: leseListe<Eintrag>(bericht.betrieb, (roh) => ({
text: typeof roh.text === 'string' ? roh.text : '',
stunden: typeof roh.stunden === 'number' && Number.isFinite(roh.stunden) ? roh.stunden : 0,
})),
schule: leseListe<Schulwoche>(bericht.schule, (roh) => ({
lernfeld: typeof roh.lernfeld === 'string' ? roh.lernfeld : '',
themen: typeof roh.themen === 'string' ? roh.themen : '',
stunden: typeof roh.stunden === 'number' && Number.isFinite(roh.stunden) ? roh.stunden : 0,
})),
sonstiges: bericht.sonstiges,
};
}
function leseListe<T>(roh: string, abbilden: (wert: Record<string, unknown>) => T): T[] {
try {
const gelesen: unknown = JSON.parse(roh);
if (!Array.isArray(gelesen)) return [];
return gelesen
.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null)
.map(abbilden);
} catch {
return [];
}
}
export function inhaltSpeichern(berichtId: string, blatt: Wochenblatt): void {
datenbank()
.update(schema.wochenbericht)
.set({
betrieb: JSON.stringify(blatt.betrieb),
schule: JSON.stringify(blatt.schule),
sonstiges: blatt.sonstiges,
geaendertAm: jetzt(),
})
.where(eq(schema.wochenbericht.id, berichtId))
.run();
}
export function statusSetzen(berichtId: string, status: BerichtStatus, zusatz: Partial<Wochenbericht> = {}): void {
datenbank()
.update(schema.wochenbericht)
.set({ status, ...zusatz, geaendertAm: jetzt() })
.where(eq(schema.wochenbericht.id, berichtId))
.run();
}
export function einreichen(berichtId: string): void {
statusSetzen(berichtId, 'eingereicht', { eingereichtAm: jetzt(), abgezeichnetAm: null, abgezeichnetVon: null });
}
export function abzeichnen(berichtId: string, ausbilderId: string, anmerkung: string | null): void {
statusSetzen(berichtId, 'abgezeichnet', {
abgezeichnetVon: ausbilderId,
abgezeichnetAm: jetzt(),
anmerkung,
});
}
export function zurueckgeben(berichtId: string, anmerkung: string): void {
statusSetzen(berichtId, 'zurueckgegeben', {
anmerkung,
eingereichtAm: null,
abgezeichnetAm: null,
abgezeichnetVon: null,
});
}
/** Blätter, die auf eine Abzeichnung warten – für „Was wartet auf mich". */
export function wartetAufAbzeichnung(benutzerIds: string[]): Wochenbericht[] {
if (benutzerIds.length === 0) return [];
return datenbank()
.select()
.from(schema.wochenbericht)
.where(eq(schema.wochenbericht.status, 'eingereicht'))
.orderBy(asc(schema.wochenbericht.von))
.all()
.filter((b) => benutzerIds.includes(b.benutzerId));
}
/** Wochen seit Ausbildungsbeginn, für die noch gar kein Blatt existiert. */
export function fehlendeWochen(benutzerId: string, beginn: string, bisDatum = heute()): string[] {
const vorhanden = new Set(berichteVon(benutzerId).map((b) => b.von));
const fehlend: string[] = [];
let montag = montagDerWoche(beginn);
const letzterMontag = montagDerWoche(bisDatum);
// Nur abgelaufene Wochen zählen als fehlend – die laufende noch nicht.
while (montag < letzterMontag) {
if (!vorhanden.has(montag)) fehlend.push(montag);
montag = naechsterMontag(montag);
}
return fehlend;
}
function naechsterMontag(montag: string): string {
const d = new Date(`${montag}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() + 7);
return d.toISOString().slice(0, 10);
}