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]
|
## [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
|
## [0.6.0] – 2026-09-10
|
||||||
|
|
||||||
Checklisten mit Durchläufen – und der erste PDF-Export.
|
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
|
- `npm run konto:anlegen` für das erste Ausbilderkonto
|
||||||
|
|
||||||
[Unveröffentlicht]: #unveröffentlicht
|
[Unveröffentlicht]: #unveröffentlicht
|
||||||
|
[0.7.0]: #070--2026-09-10
|
||||||
[0.6.0]: #060--2026-09-10
|
[0.6.0]: #060--2026-09-10
|
||||||
[0.5.0]: #050--2026-09-10
|
[0.5.0]: #050--2026-09-10
|
||||||
[0.4.0]: #040--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
|
## 2026-09-10 · Baustein 6 – Checklisten und PDF
|
||||||
|
|
||||||
| Von | Bis | Dauer | Tätigkeit |
|
| 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
|
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
|
Arbeit stehen, die der Azubi nicht selbst gemacht hat. Für die Abnahme gibt es
|
||||||
die Praxisaufgaben – dort ist es genau andersherum.
|
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
|
Gebaut für **einen Ausbilder und ein bis drei Auszubildende**. Kein
|
||||||
Lernplattform-Baukasten, keine Mandanten, keine Cloud.
|
Lernplattform-Baukasten, keine Mandanten, keine Cloud.
|
||||||
|
|
||||||
> **Stand:** Bausteine 1 bis 6 von 10 sind fertig – Fundament, Inhalte,
|
> **Stand:** Bausteine 1 bis 7 von 10 sind fertig – Fundament, Inhalte,
|
||||||
> Wochenplan, Merkkarten, Praxisaufgaben, Checklisten. Der
|
> Wochenplan, Merkkarten, Praxisaufgaben, Checklisten und das Berichtsheft.
|
||||||
> [Fahrplan](ROADMAP.md) zeigt, was folgt, das
|
> Der [Fahrplan](ROADMAP.md) zeigt, was folgt, das
|
||||||
> [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
|
> [Ä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] Abgeschlossene Durchläufe bleiben als Nachweis und sind gesperrt
|
||||||
- [x] PDF-Export mit Unterschriftenzeilen
|
- [x] PDF-Export mit Unterschriftenzeilen
|
||||||
- [x] Ausbilder liest mit, hakt aber nicht für den Azubi ab
|
- [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
|
## 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 seite.waitForSelector('h1');
|
||||||
await bild(seite, '18-checklisten');
|
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');
|
const lauf = await seite.$('.laufzeile-haupt');
|
||||||
if (lauf) {
|
if (lauf) {
|
||||||
await lauf.click();
|
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: '/karten', titel: 'Merkkarten' },
|
||||||
{ pfad: '/aufgaben', titel: 'Aufgaben' },
|
{ pfad: '/aufgaben', titel: 'Aufgaben' },
|
||||||
{ pfad: '/checklisten', titel: 'Checklisten' },
|
{ pfad: '/checklisten', titel: 'Checklisten' },
|
||||||
|
{ pfad: '/berichtsheft', titel: 'Berichtsheft' },
|
||||||
{ pfad: '/glossar', titel: 'Glossar' },
|
{ pfad: '/glossar', titel: 'Glossar' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ const fuerAusbilder: Navigationseintrag[] = [
|
|||||||
{ pfad: '/karten', titel: 'Merkkarten' },
|
{ pfad: '/karten', titel: 'Merkkarten' },
|
||||||
{ pfad: '/aufgaben', titel: 'Aufgaben' },
|
{ pfad: '/aufgaben', titel: 'Aufgaben' },
|
||||||
{ pfad: '/checklisten', titel: 'Checklisten' },
|
{ pfad: '/checklisten', titel: 'Checklisten' },
|
||||||
|
{ pfad: '/berichtsheft', titel: 'Berichtshefte' },
|
||||||
{ pfad: '/glossar', titel: 'Glossar' },
|
{ pfad: '/glossar', titel: 'Glossar' },
|
||||||
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { aktuelleWochensicht, kurzstand } from '@/lib/planung/sicht';
|
|||||||
import { alsDatumText, alsZeitpunktText, heute, kalenderwocheText } from '@/lib/zeit';
|
import { alsDatumText, alsZeitpunktText, heute, kalenderwocheText } from '@/lib/zeit';
|
||||||
import { inhalte } from '@/lib/inhalte';
|
import { inhalte } from '@/lib/inhalte';
|
||||||
import { wartetAufAbnahme } from '@/lib/repos/aufgaben';
|
import { wartetAufAbnahme } from '@/lib/repos/aufgaben';
|
||||||
|
import { wartetAufAbzeichnung } from '@/lib/repos/berichtsheft';
|
||||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||||
import { WochenAnsicht } from './WochenAnsicht';
|
import { WochenAnsicht } from './WochenAnsicht';
|
||||||
|
|
||||||
@@ -148,7 +149,10 @@ function AusbilderUeberblick({ ausbilderId, name }: { ausbilderId: string; name:
|
|||||||
function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
|
function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
|
||||||
const meine = ausbildungenVonAusbilder(ausbilderId);
|
const meine = ausbildungenVonAusbilder(ausbilderId);
|
||||||
const alle = inhalte();
|
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 (
|
return (
|
||||||
<section className="karte stapel-eng" aria-labelledby="wartet">
|
<section className="karte stapel-eng" aria-labelledby="wartet">
|
||||||
@@ -156,13 +160,27 @@ function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
|
|||||||
Was auf dich wartet
|
Was auf dich wartet
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{abnahmen.length === 0 ? (
|
{gesamt === 0 ? (
|
||||||
<p className="leise" style={{ marginBottom: 0 }}>
|
<p className="leise" style={{ marginBottom: 0 }}>
|
||||||
Nichts zur Abnahme. Unkorrigierte Lernzielkontrollen und nicht abgezeichnete Berichtshefte kommen
|
Nichts offen. Unkorrigierte Lernzielkontrollen kommen hier dazu, sobald dieser Baustein steht.
|
||||||
hier dazu, sobald diese Bausteine stehen.
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="offen-liste">
|
<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) => {
|
{abnahmen.map((stand) => {
|
||||||
const aufgabe = alle.aufgabeNach.get(stand.aufgabeId);
|
const aufgabe = alle.aufgabeNach.get(stand.aufgabeId);
|
||||||
const azubi = benutzerNachId(stand.benutzerId);
|
const azubi = benutzerNachId(stand.benutzerId);
|
||||||
|
|||||||
@@ -1341,3 +1341,208 @@ input[type="file"] {
|
|||||||
.schritt-notiz[open] > form { display: none; }
|
.schritt-notiz[open] > form { display: none; }
|
||||||
.hakenknopf { border-color: #666; }
|
.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 Benutzer = typeof benutzer.$inferSelect;
|
||||||
export type Sitzung = typeof sitzung.$inferSelect;
|
export type Sitzung = typeof sitzung.$inferSelect;
|
||||||
export type Ausbildung = typeof ausbildung.$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 Anhang = typeof anhang.$inferSelect;
|
||||||
export type ChecklisteLauf = typeof checklisteLauf.$inferSelect;
|
export type ChecklisteLauf = typeof checklisteLauf.$inferSelect;
|
||||||
export type ChecklisteSchritt = typeof checklisteSchritt.$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. */
|
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
|
||||||
export const alleTabellen = {
|
export const alleTabellen = {
|
||||||
@@ -242,4 +289,6 @@ export const alleTabellen = {
|
|||||||
anhang,
|
anhang,
|
||||||
checklisteLauf,
|
checklisteLauf,
|
||||||
checklisteSchritt,
|
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);
|
||||||
|
}
|
||||||