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>
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 67 KiB |
@@ -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> – „{warnung.auszug}“</em> : null}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
Das sind Hinweise, keine Sperre. Du kannst jederzeit einreichen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Meldung zustand={speicherZustand} />
|
||||
|
||||
{!gesperrt ? (
|
||||
<div className="reihe nicht-drucken">
|
||||
<Absenden className="knopf knopf-zweit">Speichern</Absenden>
|
||||
<button type="submit" className="knopf knopf-akzent" formAction={einreichen}>
|
||||
{ernste.length > 0 ? `Trotz ${ernste.length} Warnungen einreichen` : 'Einreichen'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Meldung zustand={einreichZustand} />
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Feldwarnungen({
|
||||
warnungen,
|
||||
feld,
|
||||
}: {
|
||||
warnungen: ReturnType<typeof pruefeWochenblatt>;
|
||||
feld: string;
|
||||
}) {
|
||||
const eigene = warnungen.filter((w) => w.feld === feld);
|
||||
if (eigene.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul className="feldwarnung">
|
||||
{eigene.map((warnung, stelle) => (
|
||||
<li key={stelle} data-schwere={warnung.schwere}>
|
||||
{warnung.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const beschriftung: Record<string, string> = {
|
||||
tagesnotiz: 'Tagesnotiz',
|
||||
aufgabe: 'Aufgabe',
|
||||
checkliste: 'Checkliste',
|
||||
handout: 'Lerninhalt',
|
||||
};
|
||||
|
||||
function zahlText(wert: number): string {
|
||||
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
|
||||
}
|
||||
@@ -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> – „{warnung.auszug}“</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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
];
|
||||
@@ -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))];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||