10 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 6181ec4bd7 Pruefskripte: wiederholbar und ehrlich machen
Die Dichtheitspruefung der Musterloesungen las die Server-Nutzlast aus
self.__next_f - das Feld leert Next nach der Verarbeitung, es war also
immer leer. Die Pruefung lief an dieser Stelle ins Leere, ohne dass es
auffiel; nur der HTML-Quelltext wurde wirklich geprueft. Gelesen wird
jetzt der Quelltext der eingebetteten Skripte. Damit zeigt die Ausgabe,
was sie zeigen soll: vor der Abgabe steht fuenfmal "musterloesung":null
in der Nutzlast, danach stehen dort die Loesungen.

Ausserdem stellt das Skript seine Ausgangslage selbst her und laeuft
gegen den Port, der ueber LERNBAR_BASIS eingestellt ist - vorher war
3311 fest eingetragen und ein zweiter Lauf scheiterte daran, dass die
Kontrolle schon abgegeben war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:36:29 +02:00
Florian WildeandClaude Opus 5 c61d5711e0 Baustein 10b: Anleitung mit Screenshots, Abnahmelauf, Fassung 1.0.0
docs/ANLEITUNG.md: 20 Abschnitte, 27 Bildschirmfotos, getrennt fuer
Ausbilder und Azubi, dazu "Wenn etwas nicht geht". Die Bilder entstehen
automatisiert ueber npm run screenshots - was dort steht, ist gemessen
und nicht gemalt.

scripts/pruefungen/abnahme.mjs (npm run abnahme) laeuft gegen eine
frische, leere Datenbank in einem Wegwerfverzeichnis: aufsetzen, Konten
anlegen, arbeiten, abnehmen, sichern, beschaedigen, zurueckspielen. Eine
Sicherung, die nie zurueckgespielt wurde, ist keine Sicherung. 33 von 33
Pruefungen erfuellt, Protokoll in docs/ABNAHME.md.

Dabei drei echte Fehler gefunden, alle in Wegen, die nie ausgefuehrt
worden waren:

- Die Sicherungsskripte liefen nicht in Git Bash, obwohl das in ihrem
  Kopf stand: tar deutet den Doppelpunkt in "C:\..." als Rechnernamen.
  Beide wechseln jetzt erst ins Verzeichnis und uebergeben einen blossen
  Dateinamen.
- Auf einer frischen Datenbank gibt es noch kein Wochenblatt; der Weg
  jedes neuen Azubis war nie durchlaufen worden.
- Der Abnahmelauf selbst liess unter Windows seinen Server ueberleben
  (kill() beendet nur die cmd.exe) und prueft deshalb jetzt vorher, ob
  der Port frei ist - sonst haette er gegen fremde Daten geprueft.

Fassung 1.0.0: package.json stand noch auf 0.1.0, waehrend das
Aenderungsprotokoll bei 0.9.0 war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:26:34 +02:00
Florian WildeandClaude Opus 5 2babef9293 Baustein 10a: PWA, Offlinebetrieb, Druckansichten
Installierbar: Manifest, Symbole aus dem eigenen Zeichen erzeugt
(scripts/symbole.ts, kein Bilddienst), Verknuepfungen zu Lernmodus,
Berichtsheft und Handouts.

Offline gilt eine Regel, und die steht im Dienstarbeiter: Nur das
Lernmaterial liegt im Browserspeicher - Handouts, Merkkarten, Glossar,
die eigene Woche. Lernzielkontrollen, Berichtsheft, Bewertungen,
Feedbackgespraeche, Anhaenge und jedes PDF niemals. Das ist keine
Sparsamkeit: Was im Browsercache liegt, ist an der serverseitigen
Rechtepruefung vorbei.

Bewertungen ohne Netz gehen in eine Warteschlange und werden
nachgereicht. Die Schlange haengt an der Benutzerkennung, nicht am
Geraet - auf einem geteilten Werkstattgeraet bekaeme sonst der Zweite
die offenen Bewertungen des Ersten aufs Konto. Der Tag der Bewertung
kommt vom Geraet und wird auf heute gedeckelt (tagDerBewertung, 4
Tests): Eine vorgestellte Uhr darf das Wiederholen nicht wegstellen.

Druck: Wer die dunkle Fassung eingestellt hatte, druckte bisher schwarze
Flaechen. Im Druck wird jetzt die komplette helle Palette gesetzt, dazu
A4-Raender, keine Umbrueche mitten in Karten und Zeilen, aufgeklappte
details. Druckknoepfe bei Handout, Glossar und Auswertung.

Durchstich in scripts/pruefungen/baustein-10.mjs mit echtem Abschalten
des Netzes: 33 Pruefungen, darunter der Nachweis, dass nichts
Bewertungsrelevantes im Speicher landet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:47:06 +02:00
Florian WildeandClaude Opus 5 be2a674c7a Baustein 9: Auswertung, Rahmenplanabdeckung, Feedbackgespraech
Die Abdeckung des Ausbildungsrahmenplans ist die Ansicht, die gegenueber
der IHK zaehlt. Sie unterscheidet vier Lagen statt "erledigt/offen":

  vermittelt   eine abgenommene Aufgabe oder ein abgeschlossener
               Checklisten-Durchlauf, oder zwei gelesene Handouts
  angefangen   etwas erledigt, aber noch nicht genug
  geplant      Inhalt vorhanden, nichts davon bearbeitet
  ohne Inhalt  zu dieser Position gibt es im Lehrgang gar nichts

Die vierte Lage ist die wichtigste: Sie ist keine Luecke beim Azubi,
sondern eine im Lehrgang, und wird getrennt ausgewiesen. Die Regel steht
als reine Funktion in src/lib/auswertung/abdeckung.ts, mit 17 Tests.

Das Feedbackgespraech laesst beide Seiten getrennt bewerten. Wo sie um
zwei Stufen oder mehr auseinanderliegen, wird das hervorgehoben - in der
Ansicht, in der Uebersicht und im PDF, alle drei ueber dieselbe Funktion
liegtWeitAuseinander(). Nach beidseitiger Bestaetigung ist das Gespraech
unveraenderlich und wird nur noch gelesen.

Durchstich in scripts/pruefungen/baustein-9.mjs: alle fuenf Luecken
erkannt, getrennte Spalten, Sperre serverseitig, PDF nur mit Anmeldung,
ein fremder Azubi bekommt 404 - auch auf das PDF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:08:49 +02:00
Florian WildeandClaude Opus 5 d226a6bae8 Baustein 8: Lernzielkontrollen mit Freigabe, Abgabe und Korrektur
- Ausbilder gibt frei; vorher sieht der Azubi die Fragen nicht
- Freigabe laesst sich zuruecknehmen, solange nicht abgegeben wurde
- Antworten werden beim Tippen je Frage zwischengespeichert
- Musterloesungen erst nach dem Absenden: sichtbareKontrolle() entfernt sie
  serverseitig, bevor die Daten in die Ausgabe gelangen
- Korrektur mit Punkten und Anmerkung je Frage, Summe und Note laufen mit
- Notenschluessel der IHK; solange eine Frage offen ist, bleibt das Ergebnis
  vorlaeufig und die Kontrolle gilt nicht als korrigiert
- Ergebnis bei beiden sichtbar, Schnitt ueber alle Kontrollen
- Abgegebene Kontrollen unter "Was auf dich wartet" und im Wochenfortschritt
- scripts/pruefungen/musterloesung-dichtheit.mjs prueft die Sperre gegen den
  echten Wortlaut aus der Inhaltsdatei im rohen Seitenquelltext

36 neue Tests, davon 21 fuer die Sichtbarkeit der Musterloesung.
Insgesamt 277.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:57:00 +02:00
Florian WildeandClaude Opus 5 7c4a051347 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>
2026-09-10 23:09:54 +02:00
Florian WildeandClaude Opus 5 b9d6041f3b Baustein 6: Checklisten mit Durchlaeufen und PDF-Export
- Durchlauf aus einer Vorlage, mit Datum, Ticket und Kunde
- Schritt fuer Schritt abhaken, Bemerkung je Schritt
- Fortschritt gesamt und je Gruppe (Vorbereiten/Durchfuehren/Pruefen/Abschliessen)
- Abschliessen erst, wenn alles abgehakt ist; Meldung nennt die offenen Schritte
- Abgeschlossene Durchlaeufe sind gesperrt und bleiben als Nachweis
- Titel und Schritttexte werden beim Start kopiert, nicht verwiesen
- PDF-Export mit gezeichneten Ankreuzkaestchen und Unterschriftenzeilen
- Gemeinsame PDF-Grundlage fuer alle kuenftigen Ausgaben
- Ausbilder liest mit und darf das PDF abrufen, hakt aber nicht ab
- Abgeschlossene Durchlaeufe zaehlen im Wochenfortschritt mit

Behoben beim Durchstich: Die Fusszeile wurde unterhalb des Satzspiegels
gesetzt, wodurch PDFKit je Fusszeile eine leere Seite anhaengte - aus zwei
Seiten wurden sechs. Ein Test zaehlt jetzt die Seitenobjekte im PDF.

29 neue Tests, insgesamt 186.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 21:00:30 +02:00
Florian WildeandClaude Opus 5 ffbca01bdc Baustein 5: Praxisaufgaben mit Anhaengen und Abnahme
- Status offen / in Arbeit / zur Abnahme / abgenommen, mit Zeitstempeln
- Notizfeld fuer den Azubi, Dateianhaenge bis 25 MB
- Speicherpfad nur aus selbst erzeugter UUID, nie aus dem Dateinamen
- Auslieferung ueber /anhaenge/<kennung> mit Rechtepruefung bei jedem Abruf,
  CSP sandbox und nosniff; SVG ausdruecklich nicht erlaubt
- Anhaenge haengen an Aufgabe UND Azubi
- Ausbilder hakt Kriterien einzeln ab, Note und Rueckmeldung, Abnahme sperrt
- Zurueckgeben zur Nacharbeit
- "Was auf dich wartet" zeigt offene Abnahmen
- Abgenommene Aufgaben zaehlen im Wochenfortschritt mit

Behoben: Next.js deckelt Serveraktionen ab Werk bei 1 MB - damit waere kein
Handyfoto durchgekommen. bodySizeLimit jetzt 30 MB, Vorabpruefung im Browser,
client_max_body_size im Proxy-Beispiel auf 30m.

Geaendert: Seiten verweigern fremde Daten mit 404 statt mit einem Fehler.
Eigene Seiten fuer "Nicht gefunden" und Stoerfaelle.

24 neue Tests, insgesamt 157.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 12:18:54 +02:00
Florian WildeandClaude Opus 5 bb592ad2bd Baustein 4: Merkkarten mit Wiederholung nach Zeitabstand
- Abgespecktes SM-2 mit drei Bewertungsstufen statt sechs
- Abstaende 1, 3, dann Abstand mal Leichtigkeit; gedeckelt bei 180 Tagen
- "nochmal" bringt die Karte in derselben Sitzung wieder
- Taeglicher Stapel, gedeckelt auf 20 Karten, ueberfaellige zuerst
- Fuenf Faecher zur Anzeige, abgeleitet aus dem Abstand
- Filter nach Themenblock fuer gezieltes Ueben
- Lernmodus fuers Handy: grosse Flaechen, Antippen, Wischen, kein Hover;
  am Rechner zusaetzlich Leertaste und Tasten 1/2/3
- Tabellen karte und karten_verlauf, jede Bewertung wird mitgeschrieben
- Merkkarten zaehlen im Wochenfortschritt mit, gelernt ab Fach 4
- Kopfzeile am Handy einzeilig, Tipp-Hervorhebung abgeschaltet
- 29 neue Tests, insgesamt 133

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:16:40 +02:00
Florian WildeandClaude Opus 5 103a603eb4 Baustein 3: Wochenplan als Startseite, Fortschritt, Wochen verschieben
- Wochenplan ist die Startseite des Azubis: Thema, Leitfrage, fuenf Tage
  mit Theorie und Praxis, Fortschrittsbalken, was noch offen ist
- Ausbilder sieht je Azubi die laufende Woche mit Stand und offenen Punkten
- /woche/<nr> fuer jede Woche, Ausbilder waehlt ueber ?azubi= den Stand;
  Zugriff wird serverseitig bei jedem Aufruf geprueft
- Tabelle wochenplanung mit geplantem und tatsaechlichem Datum, Zustand
  und Notiz fuer den Azubi
- Verschieben per Datumsfeld, wahlweise mit allen folgenden Wochen
- Reihenfolge per Ziehen und Ablegen sowie per Tastatur (Alt + Pfeil)
- Warnung bei sich ueberlappenden Terminen
- Plan entsteht automatisch beim Anlegen eines Azubikontos
- Planungs- und Fortschrittslogik als reine Funktionen, 36 Tests dafuer
- Insgesamt 104 Tests

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:04:39 +02:00
166 changed files with 18911 additions and 78 deletions
+352
View File
@@ -26,6 +26,350 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
## [Unveröffentlicht]
## [1.0.0] – 2026-09-11
Die erste abnahmefähige Fassung. Alle zehn Bausteine sind fertig, die
Abnahmekriterien einzeln durchgespielt und protokolliert.
### Hinzugefügt
- **Installierbar** als Anwendung auf dem Startbildschirm: Manifest, Symbole
in drei Fassungen (darunter eine für den Beschnitt unter Android), erzeugt
aus dem eigenen Zeichen mit `npm run symbole` – ohne Bilddienst, ohne Netz
- **Offlinebetrieb** für den Fall, für den er gedacht ist: der Azubi im
Serverraum, im Keller, im Zug
- Merkkarten und schon geöffnete Handouts funktionieren weiter
- Bewertungen gehen in eine Warteschlange und werden nachgereicht, sobald
die Verbindung wieder steht. Angerechnet wird der Tag, an dem **gelernt**
wurde, nicht der Tag des Absendens
- Eine schmale Leiste sagt, wie viele Bewertungen warten – und meldet sich,
wenn sie angekommen sind
- **Nichts Bewertungsrelevantes liegt im Browserspeicher.** Weder
Lernzielkontrollen noch Berichtsheft, Bewertungen, Feedbackgespräche,
Anhänge oder PDFs. Was im Cache liegt, ist an der serverseitigen
Rechteprüfung vorbei – deshalb liegt es dort nicht
- Beim Abmelden wird alles Zwischengespeicherte weggeräumt
- **Druckansichten** mit Knöpfen bei Handout, Glossar und Auswertung
- **`docs/ANLEITUNG.md`** – Anleitung mit 27 Bildschirmfotos, für Ausbilder
und Azubi getrennt, dazu ein Abschnitt „Wenn etwas nicht geht"
- **`scripts/pruefungen/abnahme.mjs`** – der Abnahmelauf gegen eine frische,
leere Datenbank in einem Wegwerfverzeichnis: aufsetzen, Konten anlegen,
arbeiten, abnehmen, sichern, beschädigen, zurückspielen. Das Protokoll
landet in `docs/ABNAHME.md`
- `scripts/pruefungen/baustein-10.mjs` – Durchstich mit echtem Abschalten des
Netzes
### Behoben
- **Im Druck wurde die dunkle Fassung mitgedruckt.** Wer dunkel eingestellt
hatte, bekam schwarze Flächen aufs Papier. Für den Druck wird jetzt die
vollständige helle Palette gesetzt, dazu A4-Ränder, keine Umbrüche mitten in
Karten und Zeilen, und aufklappbare Abschnitte drucken aufgeklappt
- **Die Sicherungsskripte scheiterten an Pfaden mit Doppelpunkt.** `tar` deutet
einen Doppelpunkt im Dateinamen als Rechnernamen – aus `C:\...` wurde der
Rechner `C`. Beide Skripte wechseln jetzt erst ins Verzeichnis und
übergeben einen blossen Dateinamen. Im Kopf der Skripte stand, sie liefen
auch in Git Bash; das stimmte bis jetzt nicht
- Das Skript für die Bildschirmfotos suchte Wochenblatt und
Checklisten-Durchlauf auf der Kontrollseite, wo es sie nicht gibt – zwei
Bilder waren seit Wochen veraltet
## [0.9.0] – 2026-09-11
Auswertung, Rahmenplanabdeckung und das Feedbackgespräch.
### Hinzugefügt
- **Stand des Azubis**: gelesene Handouts, sitzende Merkkarten, abgenommene
Praxisaufgaben, abgezeichnete Berichtsheftblätter – vier Kacheln, keine
Diagramme um ihrer selbst willen
- **Abdeckung des Ausbildungsrahmenplans** – die Ansicht, die gegenüber der IHK
zählt. Statt „erledigt / offen" gibt es vier Lagen:
- **vermittelt** – es gibt eine abgenommene Praxisaufgabe oder einen
abgeschlossenen Checklisten-Durchlauf, oder mindestens zwei gelesene Handouts
- **angefangen** – ein gelesenes Handout allein genügt bewusst nicht
- **geplant** – Inhalt vorhanden, aber unbearbeitet
- **ohne Inhalt** – zu dieser Position gibt es im Lehrgang überhaupt nichts.
Das ist keine Lücke beim Azubi, sondern eine im Lehrgang, und wird getrennt
hervorgehoben
- Je Position sind die einzelnen Nachweise aufgeführt, mit Kürzel und Zustand –
im Zweifel ist damit nachvollziehbar, worauf sich die Bewertung stützt. Jede
Position trägt ihre amtliche Fundstelle in der Verordnung
- **Lernfeldabdeckung** der Berufsschule nach Ausbildungsjahr, nach derselben Regel
- **Ergebnisverlauf** der Lernzielkontrollen mit Schnitt, bester und
schlechtester Arbeit
- **Feedbackgespräch**: Gesprächsleitfaden mit vier Abschnitten, Bewertungsbogen
mit acht Kriterien auf einer Skala von 1 bis 5, Stärken, Entwicklungsfelder,
vereinbarte Ziele
- **Beide Seiten bewerten getrennt.** Wo Einschätzung und Selbsteinschätzung
um zwei Stufen oder mehr auseinanderliegen, wird das hervorgehoben – genau
darüber lohnt das Gespräch
- Jede Seite trägt nur ihre eigene Spalte ein; der Ausbilder schreibt die
Freitexte, der Azubi liest sie
- Nach Bestätigung durch **beide** ist das Gespräch unveränderlich
- PDF für die Ausbildungsakte, mit beiden Einschätzungen nebeneinander
- `content/feedbackgespraech.yaml` – Leitfaden, Kriterien und Skala als
Inhaltsdatei, damit der Ausbilder sie an seinen Betrieb anpassen kann.
Ein geführtes Gespräch behält seine Fassung, auch wenn die Vorlage sich ändert
- 33 Tests für die Abdeckungsregel und den Gesprächsinhalt
### Geändert
- Ein abgeschlossenes Gespräch wird nur noch gelesen: keine gesperrten
Eingabefelder, keine leeren Bemerkungszeilen, kein zweites Datumsfeld –
ein Blatt für die Akte soll wie ein Dokument aussehen, nicht wie ein Formular
## [0.8.0] – 2026-09-11
Lernzielkontrollen – und die strengste Zusage des ganzen Auftrags eingelöst.
### Hinzugefügt
- **Freigabe durch den Ausbilder**: Vorher sieht der Azubi die Fragen gar
nicht. Eine Freigabe lässt sich zurücknehmen, solange nicht abgegeben wurde
- **Fragebogen** mit offenen Fragen und Punktzahl je Frage; die Antworten
werden beim Tippen zwischengespeichert, mit Zeitstempel in der Kopfzeile.
Eine Kontrolle dauert 45 Minuten – ein verlorener Text wäre nicht
wiedergutzumachen
- **Musterlösungen erst nach dem Absenden.** Die Sperre sitzt serverseitig in
`sichtbareKontrolle()`: Die Lösung wird aus den Daten entfernt, **bevor** sie
in die Ausgabe gelangt – nicht erst in der Oberfläche versteckt. Wer den
Seitenquelltext liest oder die Serverantwort mitschneidet, findet nichts
- **Korrektur**: Antwort des Azubis und Musterlösung nebeneinander, Punktefeld
und Anmerkung je Frage, Anmerkung zur ganzen Kontrolle. Summe, Prozentsatz
und Note laufen beim Verteilen der Punkte mit
- **Notenschlüssel der IHK** (92/81/67/50/30 Prozent). Solange eine Frage ohne
Punkte ist, gilt das Ergebnis als vorläufig und die Kontrolle nicht als
korrigiert
- Ergebnis erscheint bei beiden; die Übersicht des Azubis zeigt zusätzlich den
Schnitt über alle korrigierten Kontrollen
- Abgegebene, noch nicht korrigierte Kontrollen erscheinen beim Ausbilder unter
„Was auf dich wartet"
- Abgegebene Kontrollen zählen im Wochenfortschritt mit
- 36 Tests, davon 21 allein für die Sichtbarkeit der Musterlösung – für jeden
Betrachter und jeden Zustand einzeln
### Geprüft
Der Wortlaut der Musterlösungen wurde im rohen Seitenquelltext gesucht, in
jedem Zustand. Vor der Abgabe: kein einziger Treffer. Nach der Abgabe: alle
fünf. Der Prüfaufbau weist sich damit selbst als tauglich aus – er findet die
Lösungen, wenn sie da sein dürfen.
## [0.7.0] – 2026-09-10
Das Berichtsheft – der Teil, ohne den es keine Zulassung zur Abschlussprüfung
gibt.
### Hinzugefügt
- **Tägliche Stichworte**: je Werktag ein eigenes kleines Formular mit Text und
Stundenzahl. Bewusst je Tag getrennt absendbar, damit es am Handy in zwei
Minuten geht und nicht die ganze Woche neu geschrieben wird
- **Wochenblatt** mit Berichtszeitraum, betrieblichen Tätigkeiten und
Unterweisungen (je mit Stundenangabe), Berufsschule (Lernfeld, Themen,
Stunden) und Sonstiges; Zeilen lassen sich hinzufügen und entfernen
- **Vorschläge aus dem System**: aus den Tagesnotizen der Woche, den in dieser
Woche abgegebenen oder abgenommenen Praxisaufgaben, den abgeschlossenen
Checklisten-Durchläufen und den gelesenen Handouts. Ein Klick übernimmt den
Vorschlag ins Blatt, wo er bearbeitet wird
- **Prüfung vor dem Absenden**, mitlaufend beim Tippen:
- fehlende oder unglaubwürdige Stundenangaben, Wochensumme
- Floskeln wie „diverse Arbeiten" oder „wie in der Vorwoche" – aber nur, wenn
die Floskel den Eintrag ausmacht oder kein Tätigkeitsverb vorkommt
- IP- und MAC-Adressen, E-Mail-Adressen, Zugangsdaten, Telefonnummern und
Kundennamen (erkannt an der Rechtsform)
- **Warnung, keine Sperre.** Der Knopf zum Einreichen bleibt immer bedienbar
- **Formulierungshilfe**: Tätigkeitsverben nach Einsatzfeld gruppiert, sechs
Regeln und acht Gegenüberstellungen „so besser nicht / so ist es richtig"
samt Begründung
- **Abzeichnen**: Der Ausbilder sieht eingereichte Blätter unter „Was auf dich
wartet", liest sie in einer Nur-Lese-Ansicht mit derselben Prüfung und kann
abzeichnen oder zur Nacharbeit zurückgeben. Zurückgeben verlangt eine
Anmerkung – ohne sie weiß der Azubi nicht, was zu ändern ist
- Abgezeichnete Blätter sind **gesperrt**: keine Bearbeitung, keine Vorschläge,
kein Einreichen mehr
- **Fehlende Wochen** werden benannt: Für jede vergangene Woche ohne Blatt gibt
es einen Knopf zum Nachtragen. Die laufende Nummer richtet sich nach dem
Zeitraum, nicht nach dem Anlegen – wer nachträgt, bekommt die richtige Nummer
- **PDF im Layout eines üblichen IHK-Ausbildungsnachweises**: Kopffeld mit Name,
Beruf, Ausbildungsjahr und Berichtszeitraum, gerahmte Kästen mit
Stundenspalte und Zwischensummen, Anmerkung des Ausbilders,
Unterschriftenzeilen für Auszubildenden und Ausbilder. Einzeln oder als
ganzer Zeitraum mit einem Blatt je Seite
- Das Ausbildungsjahr wird aus dem Ausbildungsbeginn gerechnet
- 55 Tests für Prüfung, Vorschläge und PDF-Erzeugung
### Behoben
- Im Kopffeld des PDF liefen die beiden Spalten auseinander: `dokument.y`
wandert bei jedem Schreibvorgang weiter und taugte nicht als Bezugspunkt
innerhalb der Schleife
- Das Abzeichnen-Formular hatte keine `action` – der Knopf hätte nichts getan.
Aufgefallen ist das nur, weil ESLint die ungenutzte Serveraktion gemeldet hat
## [0.6.0] – 2026-09-10
Checklisten mit Durchläufen – und der erste PDF-Export.
### Hinzugefügt
- Aus einer Checklistenvorlage wird ein **Durchlauf**, der zu einem Datum und
wahlweise zu einem Ticket und einem Kunden oder Bereich gehört
- Schritt für Schritt abhaken, je Schritt eine Bemerkung
- Fortschritt gesamt **und je Gruppe** (Vorbereiten, Durchführen, Prüfen,
Abschließen), jeweils als Balken mit Zahlen
- Ein Durchlauf lässt sich erst abschließen, wenn jeder Schritt abgehakt ist;
die Meldung nennt, wie viele Schritte noch offen sind
- Abgeschlossene Durchläufe sind gesperrt und lassen sich nicht löschen – sie
sind der Nachweis. Wieder öffnen geht, verwerfen nur, solange nichts abgehakt ist
- **PDF-Export** eines Durchlaufs: Kopfangaben, alle Gruppen mit gezeichneten
Ankreuzkästchen, Bemerkungen, Unterschriftenzeilen für Bearbeiter und
Ausbilder, Fußzeile mit Herkunft und Seitenzahl
- Gemeinsame PDF-Grundlage (`src/lib/pdf/grundlage.ts`) für alle künftigen
Ausgaben – das Berichtsheft setzt darauf auf
- Der Ausbilder sieht die Durchläufe seiner Azubis und kann das PDF abrufen,
hakt aber nichts für sie ab: Im Nachweis soll keine Arbeit stehen, die der
Azubi nicht gemacht hat
- Titel und Schritttexte werden beim Start aus der Vorlage **kopiert**. Ein
abgeschlossener Durchlauf bleibt damit lesbar, auch wenn die Vorlage im
Repository später geändert oder gelöscht wird
- Abgeschlossene Durchläufe zählen im Wochenfortschritt mit
- 29 Tests für Gruppenfortschritt, Abschlussregel, PDF-Erzeugung und
Zeichenvorrat
### Angemerkt
- Die PDF-Ausgabe nutzt die Standardschriften jedes Betrachters (Helvetica).
Das spart eine eingebettete Schriftdatei, beschränkt den Zeichenvorrat aber
auf WinAnsi. Umlaute, ß, €, § und ° sind enthalten; Zeichen wie ✓, ○ oder ▲
nicht. `fuerPdf()` ersetzt sie, Ankreuzkästchen und Haken werden **gezeichnet**
## [0.5.0] – 2026-09-10
Praxisaufgaben: bearbeiten, belegen, abnehmen.
### Hinzugefügt
- Bearbeitungsstand je Aufgabe: *offen*, *in Arbeit*, *zur Abnahme*,
*abgenommen*; Beginn und Abgabe werden mit Zeitstempel vermerkt
- Notizfeld für den Azubi (bis 8000 Zeichen) – dieselben Notizen helfen später
beim Berichtsheft
- **Dateianhänge**: Fotos vom Aufbau, Bildschirmfotos, ein Netzplan als PDF.
Bis 25 MB je Datei, Positivliste erlaubter Typen (JPEG, PNG, WebP, GIF, HEIC,
PDF, Text, CSV)
- Der Speicherpfad wird ausschließlich aus einer selbst erzeugten UUID
gebildet, nie aus dem übermittelten Dateinamen
- Ausgeliefert wird nur über `/anhaenge/<kennung>`, mit Rechteprüfung bei
**jedem** Abruf und `Content-Security-Policy: sandbox`, damit ein Anhang
nicht im Kontext der Anwendung ausgeführt werden kann
- SVG ist ausdrücklich nicht erlaubt – es kann Skripte enthalten
- Der Ausbilder hakt die Bewertungskriterien einzeln ab, vergibt eine Note oder
kurze Bewertung und schreibt eine Rückmeldung
- Abnehmen sperrt die Aufgabe für den Azubi; Zurückgeben setzt sie auf
„in Arbeit", damit er nacharbeiten kann
- „Was auf dich wartet" auf der Ausbilderstartseite zeigt die offenen Abnahmen
mit Azubi und Abgabezeitpunkt
- Abgenommene Aufgaben zählen im Wochenfortschritt mit
- Eigene Seite „Nicht gefunden" für den angemeldeten Bereich
- 24 Tests für Dateiprüfung, Namenssäuberung, Speicherpfad und Pfadausbruch
### Behoben
- **Anhänge über 1 MB scheiterten stillschweigend.** Next.js deckelt
Serveraktionen ab Werk bei 1 MB – damit wäre kein einziges Handyfoto
durchgekommen, obwohl die Anwendung 25 MB zusagt. `bodySizeLimit` steht jetzt
auf 30 MB, und zu große Dateien werden schon im Browser abgefangen, bevor sie
übertragen werden. Im Beispiel für den Reverse Proxy steht
`client_max_body_size` entsprechend auf 30m
### Geändert
- Seiten verweigern fremde Daten jetzt mit **404** statt mit einem Fehler.
Damit verrät die Anwendung nicht, ob es die angefragten Daten überhaupt gibt –
und der Benutzer sieht einen deutschen Satz statt „Application error".
Serveraktionen brechen weiterhin hart ab
## [0.4.0] – 2026-09-10
Merkkarten mit Wiederholung nach Zeitabstand – der tägliche kleine Stapel
statt aller Karten auf einmal.
### Hinzugefügt
- **Wiederholungsverfahren** als abgespecktes SM-2: drei Bewertungsstufen
statt sechs (*sitzt*, *unsicher*, *nochmal*), weil im Alltag niemand
zwischen „fast richtig" und „mit Mühe richtig" unterscheidet
- Erste beiden Abstände fest bei 1 und 3 Tagen, danach greift die
Leichtigkeit (1,3 bis 2,8)
- Abstände wachsen zu 1, 3, 8, 22, 62, 174 Tagen, gedeckelt bei einem
halben Jahr
- „nochmal" setzt die Zählung zurück und bringt die Karte **noch in
derselben Sitzung** wieder – sonst wäre die Bewertung folgenlos
- Täglich fälliger Stapel, gedeckelt auf 20 Karten. Überfällige zuerst, nie
gelernte zuletzt: Wer drei Wochen ausgesetzt hat, findet nicht 52 Karten vor,
sondern die dringendsten 20
- Fünf Fächer im Sinne der Leitner-Kartei, nur zur Anzeige – abgeleitet aus dem
Abstand, damit niemand über Leichtigkeitswerte nachdenken muss
- Filter nach Themenblock, für gezieltes Üben vor einer Lernzielkontrolle
- **Lernmodus fürs Handy**: ganzflächige Karte, Antippen zum Umdrehen, Wischen
nach rechts für „sitzt" und nach links für „nochmal", drei Knöpfe mit
mindestens 76 Pixeln Höhe, kein Überfahren mit der Maus
- Am Rechner zusätzlich Leertaste zum Umdrehen und die Tasten 1, 2, 3
- Kartenübersicht: heute fällig, Stand je Themenblock, Verteilung über die
Fächer, die nächsten fünf fälligen Karten
- Tabellen `karte` (Lernstand) und `karten_verlauf` (jede einzelne Bewertung
mit Zeitstempel, Grundlage der späteren Auswertung)
- Merkkarten zählen im Wochenfortschritt mit; als gelernt gilt eine Karte ab
Fach 4, also wenn sie mindestens eine Woche weggelegt wurde
- 29 Tests für Abstände, Leichtigkeitsgrenzen, Fächer und Stapelauswahl
### Geändert
- Kopfzeile auf schmalen Bildschirmen: Marke und Bedienung in einer Zeile,
Navigation darunter in einer schiebbaren Zeile statt über drei Zeilen umbrechend
- `-webkit-tap-highlight-color` abgeschaltet – der Browser legte beim Antippen
eine blaue Fläche über die Lernkarte
## [0.3.0] – 2026-09-10
Der Wochenplan wird zur Startseite, und der Ausbilder kann ihn umplanen.
### Hinzugefügt
- **Wochenplan als Startseite des Azubis**: Woche, Thema, Leitfrage, Wochenziel,
die fünf Tage mit Theorie- und Praxisteil, hervorgehobener heutiger Tag,
Fortschrittsbalken und eine Liste dessen, was noch offen ist
- Der Ausbilder sieht auf seiner Startseite je Azubi die laufende Woche mit
Fortschritt und offenen Punkten, mit Sprung in die Wocheneinzelansicht
- `/woche/<nr>` zeigt jede beliebige Woche; der Ausbilder wählt über `?azubi=`,
wessen Stand er sieht – geprüft wird das serverseitig bei jedem Aufruf
- Tabelle `wochenplanung` mit geplantem und tatsächlichem Datum, Zustand
(`geplant` / `laufend` / `abgeschlossen`) und einer Notiz für den Azubi
- Wochenplanseite für den Ausbilder:
- Plan aus den Wochen des Repositorys erzeugen und fehlende Wochen anhängen
- Woche auf ein neues Datum verlegen, wahlweise mit allen folgenden Wochen;
ein beliebiges Datum wird auf den Montag seiner Woche gerundet
- Reihenfolge per Ziehen und Ablegen ändern – die Terminfolge bleibt fest,
nur die Zuordnung wechselt, damit der Plan lückenlos bleibt
- Dasselbe mit der Tastatur über Alt + Pfeil hoch/runter, damit die Funktion
nicht auf die Maus angewiesen ist
- Zustand je Woche setzen; beim Start wird das tatsächliche Datum vermerkt
- Notiz je Woche, die der Azubi in seiner Wochenansicht sieht
- Warnung, wenn sich nach einem Einzelverschieben Termine überlappen
- Der Wochenplan entsteht automatisch, sobald ein Azubikonto angelegt wird
- Fortschrittsrechnung als reine Funktion über Zählwerte, damit sie mit den
weiteren Bausteinen wachsen kann, ohne sich zu verzweigen
- 36 Tests für Planerzeugung, Verschieben, Umsortieren, „welche Woche ist dran"
und die Fortschrittsrechnung
### Angemerkt
- Praxisaufgaben, Checklisten und Lernzielkontrollen erscheinen im
Fortschritt bereits mit ihrer Sollzahl, lassen sich aber erst mit den
Bausteinen 5 bis 8 abhaken. Das ist Absicht: Die Woche zeigt, was ansteht,
auch wenn die Anwendung es noch nicht mitzählen kann
## [0.2.0] – 2026-09-10
Die Inhalte kommen ins Spiel: Handouts, Suche, Glossar – alles aus Dateien
@@ -105,5 +449,13 @@ anmelden und verwalten.
- `npm run konto:anlegen` für das erste Ausbilderkonto
[Unveröffentlicht]: #unveröffentlicht
[1.0.0]: #100--2026-09-11
[0.9.0]: #090--2026-09-11
[0.8.0]: #080--2026-09-11
[0.7.0]: #070--2026-09-10
[0.6.0]: #060--2026-09-10
[0.5.0]: #050--2026-09-10
[0.4.0]: #040--2026-09-10
[0.3.0]: #030--2026-09-10
[0.2.0]: #020--2026-09-10
[0.1.0]: #010--2026-09-10
+308
View File
@@ -7,6 +7,314 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
---
## 2026-09-11 · Baustein 10 – PWA, Offline, Druck, Anleitung
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 14:20 | 14:50 | 0:30 | Symbole, Manifest, Dienstarbeiter mit Speicherregel |
| 14:50 | 15:10 | 0:20 | Warteschlange, Schnittstelle, Netzleiste |
| 15:10 | 15:30 | 0:20 | Druckpalette, Seitenumbrüche, Druckknöpfe |
| 15:30 | 15:50 | 0:20 | Durchstich mit echtem Abschalten des Netzes |
| 15:50 | 16:20 | 0:30 | Anleitung mit Bildschirmfotos |
| 16:20 | 17:40 | 1:20 | Abnahmelauf – und die Suche nach einem Fehler, der meiner war |
**Summe Baustein 10: rund 3 h 20 min**
### Was aufgefallen ist
- Beim Offlinebetrieb ist die interessante Frage nicht, **wie** man
zwischenspeichert, sondern **was**. Ein Dienstarbeiter, der alles ablegt,
hebelt die Rechteprüfung aus: Was im Browsercache liegt, wird nie wieder
serverseitig geprüft. Deshalb gibt es eine ausdrückliche Liste – Handouts,
Merkkarten, Glossar, eigene Woche – und sonst nichts. Der Durchstich prüft
das nach, indem er eine Kontrollseite öffnet und danach im Speicher
nachsieht.
- Die Warteschlange gehört an die **Benutzerkennung**, nicht an das Gerät. Ein
Werkstattrechner wird geteilt; unter einem festen Schlüssel bekäme der
Zweite die offenen Bewertungen des Ersten aufs Konto.
- Der Tag einer nachgereichten Bewertung kommt vom Gerät – sonst verliert
jemand, der freitags im Keller lernt und sich montags verbindet, seinen
Freitag. Er wird aber **auf heute gedeckelt**: Sonst ließe sich das
Wiederholen wegstellen, indem man die Uhr vordatiert.
- Beim Druck lag ein echter Fehler: Wer die dunkle Fassung eingestellt hatte,
druckte schwarze Flächen. Aufgefallen ist das erst, weil der Durchstich die
Farben im Druckmodus ausliest, statt nur zu prüfen, dass die Navigation
verschwindet.
- **Die längste Suche des Tages war ein Fehler in meinem Prüfaufbau, nicht in
der Anwendung.** Der Abnahmelauf startet einen eigenen Server auf Port 3399.
Unter Windows beendet `kill()` bei einem über die Eingabeaufforderung
gestarteten Prozess nur diese, nicht Node darunter. Der alte Server überlebte,
belegte den Port, und der nächste Lauf prüfte gegen den **alten**
Datenbestand: Der Azubi stand in der Oberfläche, aber nicht in der Datenbank,
die ich las. Jetzt wird ohne Eingabeaufforderung gestartet, der ganze
Prozessbaum beendet – und der Lauf bricht mit einer klaren Meldung ab, wenn
der Port schon belegt ist.
- Die Sicherungsskripte behaupteten im Kopf, sie liefen auch in Git Bash. Das
stimmte nicht: `tar` deutet den Doppelpunkt in `C:\...` als Rechnernamen.
Aufgefallen ist das erst, als die Sicherung zum ersten Mal wirklich
zurückgespielt wurde – eine Sicherung, die man nie zurückspielt, ist keine.
---
## 2026-09-11 · Baustein 9 – Auswertung und Feedbackgespräch
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 11:10 | 11:25 | 0:15 | Abdeckungsregel als reine Funktion, 17 Tests |
| 11:25 | 11:35 | 0:10 | Datensammlung, Feedback-Vorlage als Inhaltsdatei |
| 11:35 | 11:50 | 0:15 | Datenmodell, Repository, Gesprächsinhalt mit 13 Tests |
| 11:50 | 12:10 | 0:20 | PDF, Auswertungsseite, Gesprächsbogen |
| 12:10 | 12:40 | 0:30 | Prüfen und Durchstich – stark ausgebremst durch die Maschine |
| 12:40 | 13:30 | 0:50 | Durchstichskript, Rechteprüfung mit zweitem Azubikonto |
| 13:30 | 14:15 | 0:45 | Feinschliff am abgeschlossenen Bogen, Abweichung im PDF |
**Summe Baustein 9: rund 3 h**
### Was aufgefallen ist
- Die eigentliche Entscheidung dieses Bausteins ist die Frage, **wann eine
Berufsbildposition als vermittelt gilt.** Eine naive Zählung („Inhalt
vorhanden") wäre wertlos: Dann wäre die Abdeckung von Anfang an voll. Ein
gelesenes Handout allein genügt ebenfalls nicht – sonst wäre sie nach dem
ersten Klick voll. Jetzt zählt eine abgenommene Aufgabe oder ein
abgeschlossener Checklisten-Durchlauf, oder zwei gelesene Handouts.
- Die vierte Lage – **„ohne Inhalt"** – ist die wichtigste und fiel mir erst
beim Schreiben auf. Eine Position, zu der es im ganzen Lehrgang kein Material
gibt, ist keine Lücke beim Azubi, sondern eine im Lehrgang. Ohne eigene
Kennzeichnung würde sie zwischen den unbearbeiteten Positionen verschwinden.
Im Beispielinhalt betrifft das fünf Positionen (A4, A10, C1, C2, C5).
- Beim Feedbackgespräch war die getrennte Bewertung beider Seiten die beste
Entscheidung. Der Wert des Blattes liegt nicht in den Zahlen, sondern in den
Stellen, wo sie auseinandergehen.
- Beim Blick auf den **abgeschlossenen** Bogen fiel auf, dass er wie ein
Formular aussah statt wie ein Dokument: gesperrte Eingabefelder, leere
Bemerkungszeilen mit Platzhaltern, ein zweites Datumsfeld neben dem Datum im
Kopf – und neben „bestätigt" stand ein offener Kreis. Ein Blatt für die Akte
soll nach Abschluss nur noch gelesen werden; jetzt sieht es auch so aus.
- Die Zwei-Stufen-Regel für Abweichungen stand an zwei Stellen und sollte im
PDF eine dritte bekommen. Sie ist jetzt `liegtWeitAuseinander()` in
`src/lib/feedback/inhalt.ts`, mit eigenen Tests – Ansicht, Übersicht und PDF
fragen dieselbe Funktion.
- **Die Maschine war der Engpass, nicht die Arbeit.** Docker Desktop hatte über
fünf Stunden CPU-Zeit verbraucht und der Defender-Echtzeitschutz läuft über
`node_modules`; ein einzelner Testlauf brauchte 4605 Sekunden bei 283 ms
reiner Rechenzeit. Ich habe das Vorgehen umgestellt: erst alles schreiben,
dann **einmal** prüfen, statt nach jedem Schritt.
---
## 2026-09-11 · Baustein 8 – Lernzielkontrollen
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 06:25 | 06:35 | 0:10 | Notenschlüssel und Auswertung als reine Rechnung, 15 Tests |
| 06:35 | 06:45 | 0:10 | Sichtbarkeitsschicht für die Musterlösung, 21 Tests |
| 06:45 | 06:55 | 0:10 | Datenmodell, Migration, Repository, Serveraktionen |
| 06:55 | 07:15 | 0:20 | Fragebogen mit Zwischenspeichern, Korrekturansicht, Übersichten |
| 07:15 | 08:50 | 1:35 | Bau und Durchstich – davon über eine Stunde Wartezeit (siehe unten) |
**Summe Baustein 8: rund 2 h 25 min, davon knapp 1 h reine Wartezeit**
### Was aufgefallen ist
- Der Kern dieses Bausteins ist eine einzige Funktion: `sichtbareKontrolle()`
entfernt die Musterlösung, bevor die Daten in die Ausgabe gelangen. Sie nimmt
bewusst den ganzen Betrachter und den Bearbeitungsstand entgegen statt eines
vorberechneten Wahrheitswerts – so kann kein Aufrufer versehentlich `true`
übergeben.
- Beim Lecktest zunächst ein Fehlalarm: Die Prüfung meldete
„Discover, Offer, Request, Acknowledge" im Quelltext. Das war meine **eigene
Testantwort**, die ich zuvor eingetippt hatte. Danach ein sauberer Aufbau,
der die Musterlösungen direkt aus der Inhaltsdatei liest und gegen Text
prüft, den ich nicht selbst eingebe. Ergebnis: vor der Abgabe null Treffer,
nach der Abgabe alle fünf – damit ist auch belegt, dass die Suche überhaupt
finden kann.
- **Eine Stunde an der Umgebung verloren.** Erst hing `next build` endlos, weil
der laufende Server `.next` festhielt und ich ihn mitten im Schreiben
abgeschossen hatte. Nach `Remove-Item .next -Recurse` lief der Bau in acht
Sekunden. Danach brauchte `next start` **2822 Sekunden** bis „Ready" – das
Dateisystem war zu dem Zeitpunkt extrem langsam, vermutlich ein Virenscanner
über dem frisch gebauten Verzeichnis. Lehre: Vor einem Bau immer erst den
Server stoppen, und ein hängender Start ist nicht zwingend ein Codefehler.
- Die laufende Summe beim Korrigieren war nachträglich die beste Idee an der
Oberfläche: Man sieht beim Verteilen der Punkte, auf welcher Note man landet,
statt es hinterher zu erfahren.
---
## 2026-09-10 · Baustein 7 – Berichtsheft
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 21:50 | 22:05 | 0:15 | Prüflogik: Stunden, Floskeln, schützenswerte Angaben – mit 31 Tests |
| 22:05 | 22:15 | 0:10 | Vorschläge aus dem System, Formulierungshilfe, 15 Tests |
| 22:15 | 22:25 | 0:10 | Datenmodell, Migration, Repository |
| 22:25 | 22:40 | 0:15 | PDF im IHK-Layout mit Kästen, Stundenspalten und Unterschriften |
| 22:40 | 23:00 | 0:20 | Seiten, Formulare, Abzeichnen, Tagesnotizen |
| 23:00 | 23:15 | 0:15 | Durchstich über den ganzen Weg, zwei Fehler behoben |
**Summe Baustein 7: rund 1 h 25 min**
### Was aufgefallen ist
- Die Floskelprüfung war der kniffligste Teil. „Diverse Arbeiten" ist eine
Floskel, „Diverse Patchkabel konfektioniert und beschriftet" nicht. Gelöst
über zwei Bedingungen: Die Prüfung schlägt an, wenn die Floskel den Eintrag
im Wesentlichen ausmacht **oder** wenn im ganzen Eintrag kein einziges
Tätigkeitsverb vorkommt.
- Bei den Kundennamen hat ein Test einen echten Fehler gefunden: „Beispiel
e. V." wurde nicht erkannt. Ursache war ein `\b` am Ende des Musters – nach
einem Punkt gibt es vor einem Leerzeichen keine Wortgrenze. Ersetzt durch
einen negativen Vorausblick.
- ESLint hat wieder einen echten Bug gefunden: Das Abzeichnen-Formular hatte
gar keine `action`, der Knopf hätte nichts bewirkt. Sichtbar wurde das nur
über die Meldung „Serveraktion zugewiesen, aber nie benutzt".
- Im PDF-Kopffeld liefen die beiden Spalten auseinander, weil ich `dokument.y`
als Bezugspunkt in der Schleife benutzt habe – der Wert wandert aber bei
jedem Schreibvorgang weiter. Wieder etwas, das erst beim Ansehen auffiel.
- Beim Prüfen des PDF-Zugriffs ohne Anmeldung kam zunächst 200. Erst der
zweite Blick mit `maxRedirects: 0` zeigte: 307 auf /anmelden, 0 Byte, kein
PDF. Playwright war der Weiterleitung gefolgt. Merke: Bei
Berechtigungsprüfungen nie den Weiterleitungen folgen lassen.
- Die laufende Nummer eines Wochenblatts richtet sich nach dem Zeitraum, nicht
nach der Reihenfolge des Anlegens. Wer eine Woche nachträgt, soll Nr. 3
bekommen und nicht Nr. 12 – die dahinterliegenden Blätter rücken auf.
---
## 2026-09-10 · Baustein 6 – Checklisten und PDF
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 20:35 | 20:42 | 0:07 | Datenmodell, Migration, Schema |
| 20:42 | 20:48 | 0:06 | Gruppenfortschritt als reine Rechnung, 16 Tests |
| 20:48 | 20:52 | 0:04 | PDF-Grundlage und Checklisten-PDF |
| 20:52 | 21:00 | 0:08 | Repository, Aktionen, Seiten, PDF-Route |
| 21:00 | 21:10 | 0:10 | Durchstich; PDF gerendert und den Fußzeilenfehler gefunden |
| 21:10 | 21:20 | 0:10 | Fehler behoben, Regressionstest, Dokumente |
**Summe Baustein 6: rund 45 min**
### Was aufgefallen ist
- **Das PDF hatte sechs Seiten, vier davon leer.** Aufgefallen ist das erst
beim Ansehen – die Prüfung „Status 200, Content-Type application/pdf" war
grün. Ursache: Die Fußzeile wird unterhalb des Satzspiegels gesetzt, und
PDFKit hält das für überlaufenden Text und legt eine neue Seite an. Bei zwei
Seiten mal zwei Textaufrufen macht das vier leere Seiten. Behoben, indem der
untere Rand für die Dauer der Fußzeile auf 0 gesetzt wird. Ein Test zählt
jetzt die Seitenobjekte im PDF und hätte den Fehler gefunden.
- Headless-Chromium rendert keine PDFs. Für den Blick aufs Ergebnis musste
der Browser mit Fenster starten (`headless: false`) – auf diesem Rechner
geht das, auf einem Server nicht. Für die spätere Prüfstrecke heißt das:
Seitenzahl und Inhalt maschinell prüfen, das Aussehen von Hand.
- Titel und Schritttexte werden beim Start des Durchlaufs kopiert statt
verwiesen. Ein Nachweis, der sich rückwirkend ändert, wenn jemand die
Vorlage anfasst, wäre keiner.
- Der Ausbilder darf das PDF abrufen und mitlesen, aber nicht abhaken. Erst
beim Schreiben der Aktion ist mir aufgefallen, dass „darf sehen" und „darf
bearbeiten" hier auseinanderfallen – daher zwei getrennte Prüffunktionen.
---
## 2026-09-10 · Baustein 5 – Praxisaufgaben
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 11:55 | 12:05 | 0:10 | Datenmodell für Aufgabenstand und Anhänge, Migration, Schema |
| 12:05 | 12:20 | 0:15 | Dateiregeln (Typen, Größe, Namenssäuberung, Speicherpfad) und 24 Tests dazu |
| 12:20 | 12:35 | 0:15 | Repositories, Serveraktionen, Ausliefer-Route mit Rechteprüfung |
| 12:35 | 13:00 | 0:25 | Aufgabenliste, Aufgabenseite, Bewertungsformular, Abnahme |
| 13:00 | 13:20 | 0:20 | Durchstich – dabei den 1-MB-Deckel gefunden und behoben |
| 13:20 | 13:35 | 0:15 | Fremdzugriff geprüft, Seiten auf 404 umgestellt, Screenshots, Dokumente |
**Summe Baustein 5: rund 1 h 40 min**
### Was aufgefallen ist
- **Der wichtigste Fund des Tages:** Next.js begrenzt Serveraktionen ab Werk auf
1 MB Rumpfgröße. Meine eigene Grenze von 25 MB war damit nie erreichbar –
jedes Handyfoto (2 bis 5 MB) wäre stillschweigend gescheitert, mit einem
Fehler nur im Serverprotokoll. Genau das Abnahmekriterium „eine Praxisaufgabe
mit Foto abgeben" hätte nicht funktioniert. Gefunden hat den Fehler nur der
Durchstich mit einer echten Datei, nicht die Testdatei mit 200 Byte.
Gegenprobe danach: 3,8-MB-Foto hochgeladen, kommt mit 3 987 781 Byte zurück.
- Die eigene Fehlerseite für „kein Zugriff" hat nicht funktioniert, weil
Next.js Fehlermeldungen im Produktionsbetrieb schwärzt – der Text kommt nie
im Browser an. Statt daran herumzubasteln liefern die Seiten jetzt 404. Das
ist ohnehin die bessere Antwort: Ob es die Daten gibt, geht einen Unbefugten
nichts an. Die Anhang-Route machte das von Anfang an so.
- Anhänge hängen an Aufgabe **und** Azubi. Beim ersten Entwurf hätte ein
zweiter Azubi die Fotos des ersten an derselben Aufgabe gesehen.
- Geprüft mit einem eigens angelegten zweiten Ausbilderkonto: fremder Anhang
404, eigener 200, der zuständige Ausbilder 200. Danach wieder gelöscht.
---
## 2026-09-10 · Baustein 4 – Merkkarten
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 11:00 | 11:20 | 0:20 | Wiederholungsverfahren: SM-2 auf drei Bewertungsstufen eingedampft, Fächer, Stapelauswahl |
| 11:20 | 11:35 | 0:15 | 29 Tests dazu – einer schlug an, der Test war falsch, nicht der Code (siehe unten) |
| 11:35 | 11:50 | 0:15 | Migration, Schema, Repository, Serveraktion für die Bewertung |
| 11:50 | 12:15 | 0:25 | Lernmodus: Karte, Umdrehen, Wischen, Tastatur; Kartenübersicht mit Fächern |
| 12:15 | 12:30 | 0:15 | Durchstich am Rechner und am Handy, zwei Mängel am Handy behoben |
| 12:30 | 12:40 | 0:10 | Karten in den Wochenfortschritt gehängt, Dokumente nachgezogen |
**Summe Baustein 4: rund 1 h 40 min**
### Was aufgefallen ist
- Ein Test schlug fehl: „gibt einen leeren Stapel zurück, wenn nichts fällig
ist". Der Code lieferte eine nie gelernte Karte zurück – und hatte recht.
Eine Karte ohne Fälligkeitsdatum muss immer fällig sein, sonst käme sie nie
dran. Ich habe die Erwartung korrigiert und den Fall in zwei Tests getrennt.
- „nochmal" muss die Karte **in derselben Sitzung** wiederbringen. Sie nur auf
morgen zu legen, wäre folgenlos gewesen – man drückt „nochmal" ja gerade,
weil man sie jetzt noch einmal sehen will.
- Am Handy legte der Browser beim Antippen eine blaue Fläche über die ganze
Lernkarte. `-webkit-tap-highlight-color: transparent` behebt das; ohne einen
Blick auf ein echtes Gerätemaß wäre es nie aufgefallen.
- Die Kopfzeile brauchte am Handy drei Zeilen und schob die Karte nach unten.
Jetzt: Marke und Bedienung in einer Zeile, Navigation darunter zum Schieben.
- Der Stapel wird bei 20 Karten gedeckelt. Ohne Deckel sitzt jemand nach drei
Wochen Pause vor 52 Karten und macht es gar nicht.
---
## 2026-09-10 · Baustein 3 – Wochenplan
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 09:05 | 09:20 | 0:15 | Datenmodell der Wochenplanung, Migration, Drizzle-Schema |
| 09:20 | 09:45 | 0:25 | Planungslogik als reine Funktionen: erzeugen, verschieben, umsortieren, „welche Woche ist dran", Überschneidungen; dazu die Fortschrittsrechnung |
| 09:45 | 09:55 | 0:10 | 36 Tests dafür – liefen beim ersten Anlauf durch |
| 09:55 | 10:10 | 0:15 | Repository-Schicht, Wochensicht aus Inhalt + Planung + Fortschritt |
| 10:10 | 10:35 | 0:25 | Wochenansicht, Startseiten für beide Rollen, Wocheneinzelansicht |
| 10:35 | 10:55 | 0:20 | Wochenplanseite: verlegen, umsortieren mit Ziehen und Tastatur, Zustand, Notizen |
| 10:55 | 11:10 | 0:15 | Durchstich im Browser, Screenshots, Dokumente nachgezogen |
**Summe Baustein 3: rund 2 h 05 min**
### Was aufgefallen ist
- Beim Umsortieren war die erste Idee, die Termine mitzuziehen. Falsch: Dann
entstehen Lücken und Überschneidungen. Richtig ist, die Folge der
Kalenderwochen als festen Rahmen zu behandeln und nur die Zuordnung zu
ändern – der Plan bleibt so immer lückenlos.
- Der Lint-Fehler „azubiId is defined but never used" im Verlegen-Formular war
kein Stilproblem, sondern ein echter Bug: Dem Formular fehlte das versteckte
Feld, das die Serveraktion ausliest. Ein Lauf hätte den Fehler zwar auch
gezeigt, aber erst nach dem Klick.
- Ziehen und Ablegen allein reicht nicht. Wer mit der Tastatur arbeitet, kommt
damit nicht weiter, deshalb tun es die Pfeilknöpfe und Alt + Pfeil genauso.
- Die Erfolgsmeldung nach „Wochen einplanen" ist nicht zu sehen, weil das
Formular danach verschwindet – die erscheinende Planliste ist die
Rückmeldung. Kein Fehler, aber es hat mich im Testskript zunächst gekostet.
---
## 2026-09-10 · Baustein 2 – Inhalte
| Von | Bis | Dauer | Tätigkeit |
+336
View File
@@ -135,3 +135,339 @@ Gründe: `gray-matter` merkt sich Ergebnisse und liefert beim zweiten Einlesen
desselben Textes ein Objekt ohne das Feld `matter` – im Betrieb fällt das nie
auf, im Test sofort. Und der Zeilenversatz für die Fehlermeldungen ist so
nachweislich richtig statt nachgebaut.
## E14 · SM-2 mit drei Bewertungsstufen statt sechs
Das Original arbeitet mit einer Skala von 0 bis 5. In der Praxis unterscheidet
niemand ehrlich zwischen „fast richtig" und „mit Mühe richtig" – und eine
Selbstbewertung, die man nicht ehrlich abgeben kann, verfälscht die Rechnung.
Deshalb: **sitzt** / **unsicher** / **nochmal**. Die Leichtigkeit bewegt sich
weiterhin zwischen 1,3 und 2,8, die Abstände folgen 1, 3, dann Abstand mal
Leichtigkeit. Gedeckelt wird bei 180 Tagen – die Ausbildung dauert drei Jahre,
weiter zu planen bringt nichts.
## E15 · „nochmal" bringt die Karte in derselben Sitzung wieder
Erste Fassung legte sie nur auf den nächsten Tag. Das ist falsch: Wer „nochmal"
drückt, will die Karte *jetzt* noch einmal sehen. Sie wird deshalb hinten an den
laufenden Stapel gehängt.
## E16 · Der tägliche Stapel wird bei 20 Karten gedeckelt
Ohne Deckel sitzt jemand nach drei Wochen Pause vor 52 fälligen Karten und
macht es gar nicht. Sortiert wird nach Überfälligkeit; nie gelernte Karten
kommen zuletzt, damit zuerst der Rückstand abgebaut wird.
## E17 · Als „gelernt" gilt eine Karte ab Fach 4
Fach 4 heißt: Abstand über drei Tage, also mindestens dreimal in Folge gekonnt.
Das ist die Schwelle, ab der die Karte im Wochenfortschritt als erledigt zählt.
Wer sie einmal richtig hatte, hat sie noch nicht gelernt.
## E18 · Anhänge: Pfad aus UUID, Auslieferung nur über eine geprüfte Route
Drei Regeln, die zusammengehören:
1. Der **Speicherpfad** ist `<jahr>/<uuid><endung>`. Der übermittelte Dateiname
landet nur als Anzeigename in der Datenbank und wird nie zum Bilden eines
Pfades benutzt. `speicherpfad()` wirft, wenn die Kennung keine UUID ist.
2. **Ausgeliefert** wird ausschließlich über `/anhaenge/<kennung>`. Es gibt
keinen Weg, über die Adresse eine beliebige Datei anzufordern, und die
Berechtigung wird bei jedem Abruf neu geprüft – nicht nur beim Hochladen.
3. Der Antwortkopf trägt `Content-Security-Policy: sandbox` und
`X-Content-Type-Options: nosniff`. Anhänge sind Daten, kein Programm.
SVG ist trotz `image/` **nicht** erlaubt: Es ist XML und kann Skripte enthalten.
## E19 · Anhänge hängen an Aufgabe **und** Azubi
Der Bezug lautet `<aufgabeId>::<azubiId>`. Beim ersten Entwurf hing er nur an
der Aufgabe – bei zwei Azubis hätte der zweite die Fotos des ersten gesehen.
## E20 · Kriterien werden über ihre Position gespeichert
`kriterien_erfuellt` ist eine JSON-Liste der Positionen in der Aufgabendatei,
etwa `[0,2,3]`. Wird die Reihenfolge der Kriterien nachträglich geändert,
verschiebt sich die Zuordnung. Neue Kriterien gehören deshalb ans Ende –
dieselbe Regel wie bei den Merkkarten (siehe E11).
## E21 · Fehlende Berechtigung ist ein 404, kein 403
Seiten rufen `erfordereSichtAufAzubi()` auf, das mit „nicht gefunden" endet.
Ein 403 würde bestätigen, dass es die angefragten Daten gibt. Serveraktionen
brechen weiterhin mit einem Fehler ab – dort wäre eine Weiterleitung sinnlos.
Nebeneffekt: Next.js schwärzt Fehlermeldungen im Produktionsbetrieb. Eine eigene
„Kein Zugriff"-Seite ließe sich anhand des Fehlertextes gar nicht erkennen.
## E22 · Der Deckel für Serveraktionen liegt über der eigenen Grenze
`serverActions.bodySizeLimit` steht auf 30 MB, die eigene Prüfung auf 25 MB.
Der Abstand ist Absicht: Eine zu große Datei soll die **eigene**, verständliche
Meldung bekommen und nicht den Rumpfgrößenfehler des Gerüsts, der nur im
Serverprotokoll auftaucht. Zusätzlich prüft der Browser vorab, damit am
Mobilfunk nicht erst 30 MB übertragen und dann abgelehnt werden.
## E23 · PDF mit Standardschriften statt eingebetteter Schrift
Die PDF-Ausgabe benutzt Helvetica, also eine der Schriften, die jeder
PDF-Betrachter mitbringt. Das spart eine eingebettete Schriftdatei im
Repository und hält die Dateien klein (ein Checklistenblatt wiegt rund 7 KB).
Der Preis ist der Zeichenvorrat **WinAnsi**. Enthalten sind alle deutschen
Umlaute, ß, €, § und °. Nicht enthalten sind Zeichen wie ✓, ○, ▲ oder Emoji.
Deshalb:
- `fuerPdf()` ersetzt typografische Feinheiten (Anführungszeichen,
Auslassungspunkte, geschützte Leerzeichen) und wirft nicht Darstellbares weg.
- Ankreuzkästchen und Haken werden **gezeichnet**, nicht gesetzt. Das sieht in
jedem Betrachter gleich aus und ist unabhängig von der Schrift.
Sollte später ein Zeichen außerhalb von WinAnsi wirklich gebraucht werden, ist
der Weg das Einbetten einer freien Schrift – nicht das Aufweichen dieser Regel.
## E24 · Fußzeilen brauchen einen aufgehobenen unteren Rand
PDFKit legt eine neue Seite an, sobald Text unterhalb des Satzspiegels
geschrieben wird. Eine Fußzeile steht dort per Definition. Ohne Gegenmaßnahme
entsteht je Seite und je Textaufruf eine leere Folgeseite.
`fusszeilen()` setzt deshalb `page.margins.bottom` kurzzeitig auf 0 und stellt
den Wert danach wieder her. Ein Test zählt die Seitenobjekte im erzeugten PDF –
damit fällt ein Rückfall sofort auf.
## E25 · Ein Durchlauf kopiert die Texte seiner Vorlage
`checkliste_lauf.titel` und `checkliste_schritt.text` werden beim Start aus der
Vorlage übernommen, statt zur Anzeigezeit nachgeschlagen zu werden.
Grund: Ein abgeschlossener Durchlauf ist ein Nachweis. Er muss auch in zwei
Jahren noch dasselbe aussagen – auch wenn die Vorlage unter `content/`
inzwischen umformuliert, erweitert oder gelöscht wurde. Die Seite zeigt einen
Hinweis, wenn es die Vorlage nicht mehr gibt, bleibt aber vollständig lesbar.
## E26 · Mitlesen und Bearbeiten sind getrennte Rechte
Beim Checklisten-Durchlauf fallen die beiden Rechte auseinander:
- **Lesen** darf der Eigentümer und sein Ausbilder (`laufZumLesen`).
- **Bearbeiten** darf nur der Eigentümer (`laufZumBearbeiten`).
Der Ausbilder hakt also nichts für seinen Azubi ab. Im Nachweis soll keine
Arbeit stehen, die der Azubi nicht selbst gemacht hat. Für die Abnahme gibt es
die Praxisaufgaben – dort ist es genau andersherum.
## E27 · Die Berichtsheftprüfung warnt, sie sperrt nicht
`pruefeWochenblatt()` liefert eine Liste von Warnungen und nichts anderes. Es
gibt bewusst **kein** `darfEinreichen()`.
Grund: Eine Anwendung, die das Einreichen verhindert, erzieht dazu, Unsinn
einzutragen, bis die Prüfung zufrieden ist. Der Azubi soll die Warnung sehen –
was er damit macht, entscheidet er. Der Knopf heißt dann eben
„Trotz 3 Warnungen einreichen".
Die Prüfung läuft im Browser beim Tippen mit und benutzt dieselbe Funktion wie
der Server. Sie ist keine Sicherheitsmaßnahme, sondern eine Hilfe.
## E28 · Floskeln werden am Zusammenhang erkannt, nicht am Wort
„Diverse Arbeiten" ist eine Floskel. „Diverse Patchkabel konfektioniert,
gemessen und im Verteiler beschriftet" ist keine. Ein reiner Wortabgleich würde
beide gleich behandeln und wäre nach dem dritten Fehlalarm nur noch lästig.
Die Prüfung schlägt deshalb nur an, wenn
- die Floskel den Eintrag im Wesentlichen ausmacht (höchstens 20 Zeichen
Rest), **oder**
- im ganzen Eintrag kein einziges Tätigkeitsverb vorkommt.
Dieselbe Verbliste trägt auch die Formulierungshilfe.
## E29 · Die laufende Nummer richtet sich nach dem Zeitraum
Wird eine vergessene Woche nachgetragen, bekommt sie die Nummer, die ihr
zeitlich zusteht; alle späteren Blätter rücken eine Nummer weiter.
Der Grund ist das Papierformular: Ein Ausbildungsnachweis wird chronologisch
abgeheftet, und Nr. 12 zwischen Nr. 3 und Nr. 4 wäre gegenüber der IHK nicht
erklärbar.
## E30 · Kundennamen werden an der Rechtsform erkannt
Personennamen lassen sich nicht zuverlässig erkennen, ohne ständig Fehlalarme
zu erzeugen. Firmennamen dagegen schon: Sie enden fast immer auf GmbH, AG, KG,
mbH, e. V. und Ähnliches.
Das Muster schließt bewusst mit einem negativen Vorausblick statt mit `\b` –
Rechtsformen wie „e. V." enden auf einen Punkt, und dort gibt es vor einem
Leerzeichen keine Wortgrenze. Genau dieser Fall war zuerst durchgerutscht.
## E31 · Die Formulierungshilfe steht im Quelltext, nicht unter content/
Tätigkeitsverben und die Gegenüberstellungen sind Bedienhilfe der Anwendung,
kein Ausbildungsinhalt. Sie ändern sich nicht mit dem Lehrgang und gehören
deshalb nicht in dieselbe Ablage wie Handouts und Aufgaben.
## E32 · Die Musterlösung wird entfernt, nicht versteckt
Der Auftrag verlangt: Musterlösungen dürfen dem Azubi erst nach dem Absenden
angezeigt werden und **niemals vorher über die Schnittstelle abrufbar** sein.
Umgesetzt ist das in `src/lib/kontrollen/sicht.ts`. `sichtbareKontrolle()`
baut die Fassung, die den Browser erreichen darf; ist die Lösung nicht
freigegeben, steht dort `null`. Es gibt keine Stelle, an der die vollständige
Kontrolle an die Oberfläche gereicht und dort ausgeblendet wird.
Zwei Feinheiten:
- Die Funktion nimmt den **ganzen Betrachter** und den Bearbeitungsstand
entgegen, nicht ein vorberechnetes `darfSehen`. Ein Aufrufer kann also nicht
aus Versehen `true` übergeben.
- Die Zwischenspeicher-Aktion gibt nur einen Zeitstempel zurück. Sie reicht
bewusst keinen Datensatz durch, in dem die Lösung mitreisen könnte.
Geprüft wird das doppelt: 21 Tests über alle Betrachter und Zustände, und ein
Durchstich, der den Wortlaut aus der Inhaltsdatei im rohen Seitenquelltext
sucht.
## E33 · Notenschlüssel der IHK, und ein vorläufiges Ergebnis bleibt vorläufig
Gerechnet wird mit dem 100-Punkte-Schlüssel der IHK (ab 92 / 81 / 67 / 50 / 30
Prozent). Wer sich daran gewöhnt, erlebt in der Abschlussprüfung keine
Überraschung.
Solange eine Frage ohne Punkte ist, gilt die Kontrolle **nicht** als
korrigiert: `korrigiert_am` bleibt leer, das Ergebnis heißt „vorläufig", und
die Kontrolle bleibt in der Liste „Was auf dich wartet". Ein halb korrigierter
Fragebogen, der als fertig erscheint, wäre schlimmer als gar keine Anzeige.
## E34 · Antworten werden beim Tippen gespeichert, je Frage einzeln
Eine Lernzielkontrolle dauert 45 Minuten. Ein Browserabsturz oder ein
versehentlich geschlossener Reiter darf nicht 45 Minuten kosten.
Gespeichert wird je Frage einzeln mit knapp einer Sekunde Verzögerung – nicht
das ganze Formular auf einmal. So bleibt jeder Schreibvorgang klein, und eine
lange Antwort auf Frage 1 wird nicht bei jedem Tastendruck in Frage 6 neu
geschrieben.
## E35 · Wann eine Berufsbildposition als vermittelt gilt
Die Regel steht an genau einer Stelle: `stufeAus()` in
`src/lib/auswertung/abdeckung.ts`.
**Vermittelt** ist eine Position, wenn es dazu gibt:
- mindestens einen **belastbaren** Nachweis – eine abgenommene Praxisaufgabe
oder einen abgeschlossenen Checklisten-Durchlauf –, **oder**
- mindestens **zwei stützende** Nachweise, also gelesene Handouts.
Die Unterscheidung ist der Kern: Bei einer abgenommenen Aufgabe hat jemand
etwas getan und ein anderer hat es gesehen. Ein gelesenes Handout sagt nur,
dass der Stoff behandelt wurde. Ein einzelnes genügt deshalb nicht – sonst wäre
die Abdeckung nach dem ersten Klick voll und die Ansicht wertlos.
Vier Lagen statt zwei:
| Lage | Bedeutung |
|---|---|
| vermittelt | die Regel oben ist erfüllt |
| angefangen | etwas erledigt, aber noch nicht genug |
| geplant | Inhalt vorhanden, nichts davon erledigt |
| **ohne Inhalt** | es gibt im ganzen Lehrgang nichts dazu |
Die vierte Lage ist die wichtigste. Sie ist keine Lücke beim Azubi, sondern
eine im Lehrgang, und wird deshalb getrennt und rot ausgewiesen. Dort hilft
kein Lernen, sondern nur ein neues Handout oder eine neue Aufgabe unter
`content/`.
Damit die Bewertung nachvollziehbar bleibt, führt die Ansicht je Position die
einzelnen Nachweise mit Kürzel und Zustand auf – und die amtliche Fundstelle
in der Verordnung.
## E36 · Das Feedbackgespräch kopiert seine Vorlage
`feedbackgespraech.inhalt` enthält die verwendeten Kriterien und die Skala,
nicht nur die Bewertungen.
Grund: Ein bestätigtes Gespräch gehört in die Ausbildungsakte. Ändert der
Ausbilder später `content/feedbackgespraech.yaml`, bleibt ein geführtes
Gespräch genau so, wie es geführt wurde. Dieselbe Überlegung wie bei den
Checklisten-Durchläufen (siehe E25).
## E37 · Beide Seiten bewerten getrennt
Ausbilder und Azubi tragen je eine eigene Spalte ein. Die Serveraktion schreibt
immer nur die Spalte dessen, der gerade angemeldet ist – eine
Selbsteinschätzung, die der Ausbilder eintragen könnte, wäre keine.
Liegen beide um zwei Stufen oder mehr auseinander, wird das Kriterium
hervorgehoben. Das ist der eigentliche Wert des Bogens: nicht die Zahlen,
sondern die Stellen, an denen sie auseinandergehen.
Bestätigen können beide einzeln; erst wenn **beide** bestätigt haben, ist das
Gespräch unveränderlich. Solange nur eine Seite bestätigt hat, lässt sich die
Bestätigung zurücknehmen.
## E38 · Was offline liegen darf – und was niemals
Die Regel steht in `public/sw.js`, in einer einzigen Funktion:
`darfOfflineLiegen()`.
**Offline liegt nur Lernmaterial:** Handouts, Merkkarten, Glossar, die eigene
Woche. **Nicht** Lernzielkontrollen, Berichtsheft, Bewertungen,
Feedbackgespräche, Anhänge und kein PDF.
Der Grund ist keine Sparsamkeit, sondern eine Zusage aus dem Pflichtenheft:
Musterlösungen und Bewertungsdaten stehen hinter einer eigenen Prüfung. Diese
Prüfung findet auf dem Server statt. Was im Browserspeicher liegt, wird beim
nächsten Zugriff nicht mehr geprüft – es wird ausgeliefert. Ein Dienstarbeiter,
der „alles" zwischenspeichert, hebelt damit genau die Zusage aus, die am
schwersten wiegt.
Seiten werden nach dem Muster **erst das Netz, dann der Speicher** geholt:
Nach einer Aktualisierung sieht man sofort die neue Fassung, und nur bei
fehlendem Netz die alte. Dateien unter `/_next/static/` tragen eine Prüfsumme
im Namen und werden zuerst aus dem Speicher bedient.
Beim Abmelden wird alles Persönliche weggeräumt – ein Werkstattgerät wird
geteilt.
## E39 · Die Warteschlange gehört zur Anmeldung, nicht zum Gerät
Offline entstandene Bewertungen liegen unter
`lernbar-warteschlange-<benutzerId>` im `localStorage`.
Unter einem festen Schlüssel bekäme auf einem geteilten Gerät der Zweite die
offenen Bewertungen des Ersten auf sein Konto geschrieben – und der Erste hätte
sie nie. Beim Abmelden wird die Schlange deshalb auch **nicht** gelöscht: Sie
gehört zur Anmeldung und wird beim nächsten Mal nachgereicht. Verworfen würde
sonst eine Lernleistung, die jemand erbracht hat.
`localStorage` und nicht IndexedDB: Es geht um eine Handvoll Einträge zwischen
zwei Verbindungen. Geht der Speicher verloren, ist eine Karte falsch
terminiert – dafür lohnt kein zweites Datenmodell.
## E40 · Der Tag einer nachgereichten Bewertung
`tagDerBewertung()` in `src/lib/offline/nachtrag.ts`, mit eigenen Tests.
Der Tag kommt vom **Gerät**, weil nur dort bekannt ist, wann tatsächlich
gelernt wurde: Wer freitags im Keller lernt und sich montags wieder verbindet,
soll seinen Freitag angerechnet bekommen.
Er wird auf **heute gedeckelt**. Sonst ließe sich das Wiederholen wegstellen,
indem man die Uhr des Geräts vordatiert – die Karte käme dann erst in ferner
Zukunft wieder. Unlesbares gilt als heute.
## E41 · Im Druck gilt immer die helle Fassung
Die vollständige helle Palette wird unter `@media print` noch einmal gesetzt,
und zwar auch für `:root[data-thema="dunkel"]`.
Der Grund ist banal und war ein echter Fehler: Wer die dunkle Fassung
eingestellt hatte, druckte schwarze Flächen. Eine einzelne Regel für `body`
genügt nicht, weil die Karten, Ränder und Marken ihre Farben aus Merkmalen
beziehen.
+46 -10
View File
@@ -9,9 +9,12 @@ und – der wichtigste Teil – den gesetzlich vorgeschriebenen Ausbildungsnachw
Gebaut für **einen Ausbilder und ein bis drei Auszubildende**. Kein
Lernplattform-Baukasten, keine Mandanten, keine Cloud.
> **Stand:** Baustein 1 von 10 ist fertig (Fundament). Der
> [Fahrplan](ROADMAP.md) zeigt, was folgt, das
> [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
> **Stand:** Alle zehn Bausteine sind fertig – Fassung 1.0.0.
>
> - [**Anleitung mit Bildschirmfotos**](docs/ANLEITUNG.md) – wie man damit arbeitet
> - [Abnahmeprotokoll](docs/ABNAHME.md) – die Abnahmekriterien, einzeln durchgespielt
> - [Änderungsprotokoll](CHANGELOG.md) · [Fahrplan](ROADMAP.md) ·
> [Entscheidungen](ENTSCHEIDUNGEN.md)
---
@@ -41,8 +44,8 @@ docker compose exec lernbar sh -c \
'node werkzeuge/konto.js --name "Vorname Nachname" --email ausbilder@betrieb.de --rolle ausbilder'
```
> Solange das Konto-Werkzeug im Abbild noch nicht liegt (Baustein 1), geht es
> vom Entwicklungsrechner aus:
> Solange das Konto-Werkzeug im Abbild noch nicht liegt, geht es vom
> Entwicklungsrechner aus:
> `npm run konto:anlegen -- --name "…" --email … --rolle ausbilder`
Danach im Browser anmelden, unter **Verwaltung** das Azubikonto anlegen und
@@ -75,7 +78,7 @@ server {
ssl_certificate /etc/ssl/lernbar.crt;
ssl_certificate_key /etc/ssl/lernbar.key;
client_max_body_size 25m; # Fotos vom Aufbau als Anhang
client_max_body_size 30m; # Fotos vom Aufbau als Anhang (Grenze der Anwendung: 25 MB)
location / {
proxy_pass http://127.0.0.1:3000;
@@ -103,6 +106,7 @@ content/
checklisten/ 02-windows-installieren.yaml …
lernzielkontrollen/ woche-03.yaml …
glossar.yaml
feedbackgespraech.yaml Leitfaden und Kriterien für das Gespräch
rahmenplan.yaml Berufsbildpositionen und Lernfelder
```
@@ -136,8 +140,6 @@ Datei und Stelle. Prüfen ohne Start:
npm run inhalte:pruefen
```
*(Wird mit Baustein 2 scharf geschaltet.)*
---
## Datensicherung
@@ -240,10 +242,44 @@ Protokollzeilen.
```bash
npm run typecheck # TypeScript
npm run lint # ESLint
npm run test # Vitest – Logik, die wehtut
npm run e2e # Playwright – der Hauptweg von Anfang bis Ende
npm run test # Vitest – Logik, die wehtut (315 Tests)
npm run abnahme # der Abnahmelauf, siehe unten
```
### Der Abnahmelauf
`npm run abnahme` ist die Prüfung, auf die es ankommt. Er legt ein
**Wegwerfverzeichnis** an, startet die Anwendung mit einer **frischen, leeren
Datenbank** und spielt den ganzen Weg durch:
1. Migrationen laufen an, das erste Ausbilderkonto entsteht auf der Kommandozeile
2. Die Inhaltsprüfung wird mit einem absichtlich eingebauten Fehler auf die
Probe gestellt – sie muss Datei, Zeile und Wert nennen
3. Der Ausbilder legt ein Azubikonto an; das Passwort darf danach nirgends mehr
auftauchen, in der Datenbank steht ein Argon2id-Hash
4. Der Azubi liest ein Handout, lernt eine Karte, meldet eine Aufgabe zur Abnahme
5. Der Ausbilder nimmt sie ab; sie schlägt in der Rahmenplanabdeckung durch
6. Das Wochenblatt wird angelegt und als PDF ausgegeben
7. **Gesichert, absichtlich beschädigt, zurückgespielt.** Eine Sicherung, die
nie zurückgespielt wurde, ist keine Sicherung
Das Protokoll landet in [`docs/ABNAHME.md`](docs/ABNAHME.md).
Voraussetzung: `npm run build` ist gelaufen, und Port 3399 ist frei – sonst
bricht der Lauf mit einer Meldung ab, statt gegen einen fremden Datenbestand zu
prüfen.
### Einzelne Zusagen
```bash
node scripts/pruefungen/musterloesung-dichtheit.mjs # Musterlösung dicht?
node scripts/pruefungen/baustein-9.mjs # Abdeckung, Feedback
node scripts/pruefungen/baustein-10.mjs # PWA, Offline, Druck
```
Diese drei brauchen eine **laufende** Anwendung unter `http://localhost:3000`
mit den Beispieldaten.
---
## Aufbau
+71 -11
View File
@@ -35,27 +35,87 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
## 0.3 – Woche und Wiederholung
- [ ] **B3 · Wochenplan als Startseite, Fortschritt, Wochen verschieben**
- [ ] **B4 · Merkkarten mit Wiederholung nach Zeitabstand (SM-2)**
- [x] **B3 · Wochenplan als Startseite, Fortschritt, Wochen verschieben**
- [x] Wochenplan ist die Startseite des Azubis: Thema, Leitfrage, fünf Tage
mit Theorie und Praxis, Fortschrittsbalken, was noch offen ist
- [x] Ausbilder sieht dieselbe Woche plus den Stand seiner Azubis
- [x] Geplantes und tatsächliches Datum je Woche
- [x] Verschieben per Datumsfeld, wahlweise mit allen folgenden Wochen
- [x] Reihenfolge per Ziehen und Ablegen **und** per Tastatur
- [x] Zustand je Woche (geplant / läuft / abgeschlossen) und Notiz für den Azubi
- [x] Warnung bei sich überlappenden Terminen
- [x] **B4 · Merkkarten mit Wiederholung nach Zeitabstand (SM-2)**
- [x] Vereinfachtes SM-2 mit drei Bewertungsstufen statt sechs
- [x] Täglich fälliger Stapel, gedeckelt auf 20 Karten
- [x] Fünf Fächer zur Anzeige, abgeleitet aus dem Abstand
- [x] Filter nach Themenblock für gezieltes Üben vor der Kontrolle
- [x] Lernmodus fürs Handy: große Flächen, Antippen, Wischen, kein Hover
- [x] Am Rechner zusätzlich Leertaste und Tasten 1 / 2 / 3
- [x] Jede Bewertung wird mitgeschrieben (Verlauf für die spätere Auswertung)
- [x] Merkkarten zählen im Wochenfortschritt mit
## 0.4 – Nachweise
- [ ] **B5 · Praxisaufgaben mit Anhängen und Abnahme**
- [ ] **B6 · Checklisten mit Durchläufen und PDF**
- [ ] **B7 · Berichtsheft: Tagesnotizen, Vorschläge, Prüfung, Abzeichnen, PDF**
- [x] **B5 · Praxisaufgaben mit Anhängen und Abnahme**
- [x] Status offen / in Arbeit / zur Abnahme / abgenommen
- [x] Notizen des Azubis, Dateianhänge (Fotos, Bildschirmfotos, PDF)
- [x] Ausbilder hakt Kriterien einzeln ab, Note und Rückmeldung
- [x] Abnehmen sperrt die Aufgabe, Zurückgeben zur Nacharbeit
- [x] Anhänge nur über Kennung, Berechtigung bei jedem Abruf geprüft
- [x] „Was auf dich wartet" zeigt offene Abnahmen
- [x] Aufgaben zählen im Wochenfortschritt mit
- [x] **B6 · Checklisten mit Durchläufen und PDF**
- [x] Durchlauf aus einer Vorlage, mit Datum, Ticket und Kunde
- [x] Schritt für Schritt abhaken, Bemerkung je Schritt
- [x] Fortschritt je Gruppe (Vorbereiten / Durchführen / Prüfen / Abschließen)
- [x] Abschließen erst, wenn alles abgehakt ist
- [x] Abgeschlossene Durchläufe bleiben als Nachweis und sind gesperrt
- [x] PDF-Export mit Unterschriftenzeilen
- [x] Ausbilder liest mit, hakt aber nicht für den Azubi ab
- [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
- [ ] **B8 · Lernzielkontrollen mit Freigabe, Abgabe, Korrektur**
- [ ] **B9 · Auswertung, Rahmenplanabdeckung, Feedbackgespräch**
- [x] **B8 · Lernzielkontrollen mit Freigabe, Abgabe, Korrektur**
- [x] Ausbilder gibt frei, kann die Freigabe vor der Abgabe zurücknehmen
- [x] Fragen erst ab der Freigabe sichtbar
- [x] Antworten werden beim Tippen zwischengespeichert
- [x] Musterlösungen erst nach dem Absenden – serverseitig entfernt,
nicht in der Oberfläche versteckt
- [x] Korrektur mit Punkten je Frage, Anmerkung je Frage und gesamt
- [x] Notenschlüssel der IHK, Zwischenstand beim Korrigieren sichtbar
- [x] Ergebnis bei beiden sichtbar, Verlauf über alle Kontrollen
- [x] **B9 · Auswertung, Rahmenplanabdeckung, Feedbackgespräch**
- [x] Stand des Azubis: Handouts, Merkkarten, Aufgaben, Berichtsheft
- [x] Rahmenplanabdeckung mit vier Lagen statt „erledigt/offen"
- [x] Lücken im Lehrgang werden getrennt ausgewiesen
- [x] Lernfeldabdeckung nach Ausbildungsjahr
- [x] Ergebnisverlauf der Lernzielkontrollen
- [x] Feedbackgespräch mit Leitfaden und Bewertungsbogen
- [x] Beide Seiten bewerten getrennt, Abweichungen werden hervorgehoben
- [x] Nach beidseitiger Bestätigung unveränderlich, PDF für die Akte
## 1.0 – Abrundung
- [ ] **B10 · PWA, Offlinebetrieb, Druckansichten, Feinschliff**
- [ ] Anleitung mit Screenshots (`docs/ANLEITUNG.md`), Bilder automatisiert
- [x] **B10 · PWA, Offlinebetrieb, Druckansichten, Feinschliff**
- [x] Installierbar: Manifest, Symbole aus dem eigenen Zeichen erzeugt
- [x] Offline: Merkkarten und gelesene Handouts, Bewertungen in einer
Warteschlange, nachgereicht bei Verbindung
- [x] Nichts Bewertungsrelevantes im Browserspeicher – nachgewiesen
- [x] Druckansichten, auch bei eingestellter dunkler Fassung
- [x] Anleitung mit Screenshots (`docs/ANLEITUNG.md`), Bilder automatisiert
über `npm run screenshots`
- [ ] Ende-zu-Ende-Test über den Hauptweg
- [ ] Abnahmekriterien einzeln durchgespielt und protokolliert
- [x] Ende-zu-Ende-Test über den Hauptweg (`scripts/pruefungen/abnahme.mjs`)
- [x] Abnahmekriterien einzeln durchgespielt und protokolliert
(`docs/ABNAHME.md`)
---
+75
View File
@@ -0,0 +1,75 @@
# ---------------------------------------------------------------------------
# Vorlage für das monatliche Feedbackgespräch.
#
# Liegt hier und nicht im Quelltext, weil der Ausbilder die Kriterien an seinen
# Betrieb anpassen soll. Wer etwas ändert, ändert es für künftige Gespräche –
# bereits bestätigte Gespräche behalten ihre Fassung (sie wird mitkopiert).
# ---------------------------------------------------------------------------
leitfaden:
- abschnitt: Rückblick
fragen:
- Was hat seit dem letzten Gespräch am meisten Spaß gemacht?
- Woran hast du gemerkt, dass du sicherer geworden bist?
- Wo hast du dich allein gelassen gefühlt?
- abschnitt: Fachliches
fragen:
- Welche Aufgabe war die schwierigste, und woran lag das?
- Was aus der Berufsschule konntest du im Betrieb anwenden?
- Wo fehlt dir Übung, um selbstständig arbeiten zu können?
- abschnitt: Zusammenarbeit
fragen:
- Wie läuft es mit den Kollegen und im Kundenkontakt?
- Bekommst du Rückmeldung, wenn etwas gut oder schlecht war?
- Traust du dich nachzufragen, wenn du nicht weiterkommst?
- abschnitt: Ausblick
fragen:
- Was willst du bis zum nächsten Gespräch erreichen?
- Was brauchst du dafür von mir?
# Die Kriterien werden auf einer Skala von 1 (sehr gut) bis 5 (deutlicher
# Nachholbedarf) bewertet – von beiden Seiten getrennt, damit ein
# unterschiedlicher Blick sichtbar wird und nicht untergeht.
kriterien:
- id: selbststaendigkeit
titel: Selbstständigkeit
erlaeuterung: Arbeitet Aufträge eigenständig ab, fragt gezielt nach, statt zu warten.
- id: sorgfalt
titel: Sorgfalt und Genauigkeit
erlaeuterung: Arbeitet sauber, prüft das Ergebnis, hinterlässt einen ordentlichen Arbeitsplatz.
- id: dokumentation
titel: Dokumentation
erlaeuterung: Tickets, Berichtsheft und Protokolle sind vollständig und nachvollziehbar.
- id: fachwissen
titel: Fachliches Verständnis
erlaeuterung: Versteht Zusammenhänge, nicht nur Handgriffe; kann erklären, was er tut.
- id: fehlersuche
titel: Systematische Fehlersuche
erlaeuterung: Grenzt ein statt zu raten, arbeitet von unten nach oben, dokumentiert den Weg.
- id: kundenkontakt
titel: Umgang mit Anwendern
erlaeuterung: Hört zu, fasst zusammen, sagt verbindlich zu und hält es ein.
- id: zuverlaessigkeit
titel: Zuverlässigkeit
erlaeuterung: Pünktlich, hält Absprachen ein, meldet sich, wenn etwas nicht klappt.
- id: lernbereitschaft
titel: Lernbereitschaft
erlaeuterung: Bereitet nach, nutzt die Merkkarten, kommt mit eigenen Fragen.
skala:
- wert: 1
text: sehr gut
- wert: 2
text: gut
- wert: 3
text: in Ordnung
- wert: 4
text: ausbaufähig
- wert: 5
text: deutlicher Nachholbedarf
hinweis: >-
Das Gespräch wird von beiden Seiten bestätigt und ist danach unveränderlich.
Es gehört in die Ausbildungsakte – deshalb: konkret bleiben, Beispiele nennen,
und die vereinbarten Ziele so formulieren, dass beim nächsten Mal feststellbar
ist, ob sie erreicht wurden.
+68
View File
@@ -0,0 +1,68 @@
# Abnahmeprotokoll
Erzeugt am 2026-09-11 17:16 Uhr
Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenbank**.
## A1 · Aus dem Nichts aufsetzbar
- ✓ Das erste Ausbilderkonto entsteht auf der Kommandozeile — ohne Fehler
- ✓ Die Datenbank wurde angelegt und die Migrationen liefen an
- ✓ Die Anwendung startet und meldet sich gesund
- ✓ Heile Inhalte werden ohne Beanstandung gelesen — Inhalte in Ordnung
- ✓ Ein Inhaltsfehler lässt die Prüfung fehlschlagen — Ende 1
- ✓ Die Meldung nennt die Datei — kaputte-inhalte/handouts/N3-subnetting-rechnen.md:7 (rahmenplan)
- ✓ Die Meldung nennt die richtige Zeile — erwartet Zeile 7
- ✓ Die Meldung nennt den fehlerhaften Wert
- ✓ Die Meldung sagt, wo die gültigen Werte stehen
## A2 · Zwei Rollen, keine Selbstregistrierung
- ✓ Die Anmeldeseite bietet keine Registrierung
- ✓ Ohne Anmeldung führt jeder Pfad zur Anmeldung — HTTP 307
- ✓ Der Ausbilder meldet sich an
## A3 · Der Ausbilder legt Konten an
- ✓ Der Azubi steht in der Verwaltung
- ✓ Das Passwort ist in der Oberfläche nirgends mehr zu sehen
- ✓ In der Datenbank steht kein Klartextpasswort
- ✓ Das Passwort liegt als Argon2id-Hash — $argon2id$v=19$m=19456,t=2,p=1
## A4 · Der Azubi arbeitet
- ✓ Der Azubi meldet sich mit dem vergebenen Passwort an
- ✓ Ein Handout lässt sich als gelesen markieren
- ✓ Eine Merkkarte lässt sich bewerten — Zuletzt: kommt morgen.
## A5 · Aufgabe abgeben und abnehmen
- ✓ Der Azubi hält seine Arbeit in Notizen fest
- ✓ Der Azubi meldet die Aufgabe zur Abnahme
- ✓ Die Aufgabe erscheint beim Ausbilder zur Abnahme
- ✓ Der Ausbilder nimmt die Aufgabe ab
- ✓ Für den Azubi ist die abgenommene Aufgabe gesperrt
- ✓ Die Abnahme schlägt in der Rahmenplanabdeckung durch
- ✓ Lücken im Lehrgang werden getrennt ausgewiesen
## A6 · Rechte serverseitig
- ✓ Der Azubi kommt nicht in die Verwaltung — HTTP 307
## A7 · Berichtsheft
- ✓ Der Azubi legt das Wochenblatt der laufenden Woche an
- ✓ Ein Wochenblatt lässt sich öffnen
- ✓ Das Wochenblatt kommt als PDF heraus — HTTP 200, 2646 Bytes
## A8 · Sicherung zurückspielen
- ✓ Die Sicherung läuft durch — lernbar-20260911-171627.tar.gz
- ✓ Zum Beweis wird der Datenbestand beschädigt — 2 → 1 Konten
- ✓ Die Rückspielung läuft durch — ohne Fehler
- ✓ Der gelöschte Azubi ist wieder da — Tim Vogt
- ✓ Auch der Lernverlauf kam mit zurück — 1 Einträge
- ✓ Der überschriebene Stand wurde zur Seite gelegt, nicht gelöscht — verdraengt-20260911-171632
**36 von 36 Prüfungen erfüllt.**
+412
View File
@@ -0,0 +1,412 @@
# Lern-Bar – Anleitung
Diese Anleitung zeigt die Anwendung so, wie sie im Betrieb benutzt wird: erst
der Ausbilder, dann der Azubi. Wer die Lern-Bar aufsetzen will, findet das im
[README](../README.md) – hier geht es um den Umgang damit.
Die Bilder stammen aus einer laufenden Anwendung mit den Beispielinhalten und
werden mit `npm run screenshots` neu erzeugt. Was hier steht, ist also nicht
gemalt, sondern gemessen.
---
## Inhalt
**Für den Ausbilder**
1. [Anmelden](#1-anmelden)
2. [Ein Azubikonto anlegen](#2-ein-azubikonto-anlegen)
3. [Den Wochenplan festlegen](#3-den-wochenplan-festlegen)
4. [Eine Praxisaufgabe abnehmen](#4-eine-praxisaufgabe-abnehmen)
5. [Das Berichtsheft abzeichnen](#5-das-berichtsheft-abzeichnen)
6. [Eine Lernzielkontrolle korrigieren](#6-eine-lernzielkontrolle-korrigieren)
7. [Die Rahmenplanabdeckung lesen](#7-die-rahmenplanabdeckung-lesen)
8. [Ein Feedbackgespräch führen](#8-ein-feedbackgespräch-führen)
**Für den Azubi**
9. [Die Woche](#9-die-woche)
10. [Handouts lesen und suchen](#10-handouts-lesen-und-suchen)
11. [Merkkarten lernen](#11-merkkarten-lernen)
12. [Eine Praxisaufgabe abgeben](#12-eine-praxisaufgabe-abgeben)
13. [Eine Checkliste abarbeiten](#13-eine-checkliste-abarbeiten)
14. [Das Berichtsheft führen](#14-das-berichtsheft-führen)
15. [Eine Lernzielkontrolle schreiben](#15-eine-lernzielkontrolle-schreiben)
**Überall**
16. [Auf dem Handy](#16-auf-dem-handy)
17. [Ohne Netz](#17-ohne-netz)
18. [Hell und dunkel](#18-hell-und-dunkel)
19. [Drucken](#19-drucken)
20. [Wenn etwas nicht geht](#20-wenn-etwas-nicht-geht)
---
## Für den Ausbilder
### 1. Anmelden
![Anmeldung](bilder/01-anmelden.png)
Es gibt **keine Selbstregistrierung**. Das erste Ausbilderkonto legst du auf der
Kommandozeile an (siehe README, Abschnitt *In fünf Minuten zum laufenden
System*), alle weiteren Konten entstehen in der Verwaltung.
Nach dem Anmelden landest du auf deinem Überblick: was in dieser Woche ansteht,
was offen ist, was auf dich wartet.
![Überblick des Ausbilders](bilder/02-ausbilder-ueberblick.png)
---
### 2. Ein Azubikonto anlegen
**Verwaltung → Neues Azubikonto**
![Verwaltung](bilder/03-verwaltung.png)
Du gibst Name, E-Mail-Adresse, ein erstes Passwort (mindestens 10 Zeichen) und
den Ausbildungsbeginn an; Probezeit und geplantes Ende sind freiwillig.
Das Passwort gibst du dem Azubi **persönlich** weiter – er kann es danach selbst
ändern. Einsehbar ist es danach nirgends mehr, auch für dich nicht: In der
Datenbank liegt nur ein Argon2id-Hash. Hat jemand sein Passwort vergessen,
setzt du in der Verwaltung ein neues; das alte lässt sich nicht anzeigen.
Der Ausbildungsbeginn ist wichtiger, als er aussieht. Aus ihm ergeben sich das
Ausbildungsjahr und damit die Wochennummern im Berichtsheft.
> **Ein Azubi hat genau einen Ausbilder.** Das ist keine Einschränkung, die sich
> später aufheben ließe, sondern die Grundlage der Rechteprüfung: Der Azubi
> sieht ausschließlich seine eigenen Daten, der Ausbilder die seiner Azubis.
---
### 3. Den Wochenplan festlegen
**Wochenplan**
![Wochenplan](bilder/04-wochenplan.png)
Die Wochen selbst stehen als Dateien unter `content/wochen/`. Hier legst du
fest, **wann** welche Woche läuft, und kannst sie verschieben – Urlaub,
Berufsschulblock, ein Projekt, das dazwischenkommt.
Verschiebst du eine Woche, wandern die folgenden mit. Was der Azubi schon
erledigt hat, bleibt erledigt.
---
### 4. Eine Praxisaufgabe abnehmen
**Aufgaben → eine abgegebene Aufgabe öffnen**
![Aufgabe abnehmen](bilder/05-aufgabe-abnahme.png)
Der Azubi gibt eine Aufgabe mit seiner Lösung und optional mit Anhängen ab –
meist Fotos vom Handy. Du siehst die Abnahmekriterien aus der Aufgabendatei und
hakst ab, was erfüllt ist.
Eine Aufgabe ist **abgenommen**, wenn du sie abgenommen hast. Nicht, wenn der
Azubi sie abgegeben hat. Genau dieser Unterschied macht sie später in der
Rahmenplanabdeckung zu einem belastbaren Nachweis.
Reicht etwas nicht, gibst du sie mit einer Bemerkung zurück. Der Azubi sieht,
was fehlt, und kann nachbessern.
---
### 5. Das Berichtsheft abzeichnen
**Berichtshefte → ein Wochenblatt öffnen**
![Wochenblatt](bilder/22-wochenblatt.png)
Der Ausbildungsnachweis ist der einzige Teil dieser Anwendung, den das Gesetz
verlangt. Deshalb ist er der sorgfältigste.
Du liest das Blatt, siehst die Warnhinweise (leere Tage, Floskeln, mögliche
Betriebs- oder Personennamen) und zeichnest ab. **Nach dem Abzeichnen ist das
Blatt unveränderlich** – auch für dich. Willst du eine Änderung, gibst du es
frei; das wird protokolliert.
Über *Als PDF* bekommst du das Blatt im IHK-Layout, mit Unterschriftenfeldern.
Ein Beispiel liegt unter [`docs/beispiel-ausbildungsnachweis.pdf`](beispiel-ausbildungsnachweis.pdf).
---
### 6. Eine Lernzielkontrolle korrigieren
**Kontrollen**
![Kontrollen](bilder/23-kontrollen.png)
Du gibst eine Kontrolle frei, der Azubi schreibt sie, du korrigierst. Punkte je
Frage, daraus Prozent, daraus die Note nach dem **IHK-Notenschlüssel**
(92/81/67/50/30 %).
![Ergebnis einer Kontrolle](bilder/24-kontrolle-ergebnis.png)
> **Die Musterlösung bekommt der Azubi erst nach dem Absenden zu sehen.** Vorher
> ist sie über keine Schnittstelle abrufbar – nicht im HTML, nicht in der
> Nachladung, nirgends. Das wird serverseitig entschieden, nicht in der
> Oberfläche, und mit einem eigenen Prüfskript nachgewiesen
> (`scripts/pruefungen/musterloesung-dichtheit.mjs`).
---
### 7. Die Rahmenplanabdeckung lesen
**Auswertung**
![Auswertung](bilder/06-auswertung.png)
Das ist die Ansicht, die gegenüber der IHK zählt. Sie beantwortet die Frage, die
im Prüfungsausschuss gestellt wird: *Ist der Ausbildungsrahmenplan abgedeckt?*
Statt „erledigt / offen" gibt es **vier Lagen**:
| Lage | Bedeutung |
|---|---|
| **vermittelt** | eine abgenommene Praxisaufgabe oder ein abgeschlossener Checklisten-Durchlauf, **oder** mindestens zwei gelesene Handouts |
| **angefangen** | etwas erledigt, aber noch nicht genug – ein gelesenes Handout allein genügt bewusst nicht |
| **geplant** | Material vorhanden, aber unbearbeitet |
| **ohne Inhalt** | zu dieser Position gibt es im ganzen Lehrgang nichts |
Die vierte Lage ist die wichtigste und wird rot ausgewiesen:
![Positionen ohne Inhalt](bilder/07-rahmenplanabdeckung.png)
**Das ist keine Lücke beim Azubi, sondern eine im Lehrgang.** Dort hilft kein
Lernen, sondern nur ein neues Handout oder eine neue Aufgabe unter `content/`.
In den Beispielinhalten betrifft das fünf Positionen: A4, A10, C1, C2 und C5.
Jede Position führt ihre einzelnen Nachweise mit Kürzel und Zustand auf und
nennt ihre amtliche Fundstelle in der Verordnung. Wenn jemand fragt, worauf sich
die Bewertung stützt, steht die Antwort da.
---
### 8. Ein Feedbackgespräch führen
**Feedback → Neues Gespräch anlegen**
![Feedbackgespräche](bilder/08-feedback-liste.png)
Monatlich, mit Leitfaden und Bewertungsbogen. Der Ablauf:
1. Du legst das Gespräch an und trägst **deine** Einschätzung ein: acht Kriterien
auf einer Skala von 1 bis 5, dazu Stärken, Entwicklungsfelder und die
vereinbarten Ziele.
2. Der Azubi trägt **seine** Selbsteinschätzung ein. Er sieht deine Freitexte,
kann sie aber nicht ändern – und du kannst seine Selbsteinschätzung nicht
eintragen. Eine Selbsteinschätzung, die der Ausbilder eintragen könnte, wäre
keine.
3. Wo beide um **zwei Stufen oder mehr** auseinanderliegen, wird das
hervorgehoben. Genau darüber lohnt das Gespräch – nicht über die Zahlen,
sondern über die Stellen, an denen sie auseinandergehen.
4. Beide bestätigen. Danach ist das Gespräch **unveränderlich**.
![Feedbackgespräch](bilder/09-feedbackgespraech.png)
*Als PDF für die Akte* gibt beide Einschätzungen nebeneinander aus, mit
Unterschriftenfeldern. Abweichungen sind auch dort markiert.
> Die Kriterien und der Leitfaden stehen in
> `content/feedbackgespraech.yaml` und lassen sich an den eigenen Betrieb
> anpassen. Ein bereits geführtes Gespräch behält seine Fassung, auch wenn du
> die Vorlage später änderst.
---
## Für den Azubi
### 9. Die Woche
![Woche des Azubis](bilder/10-azubi-woche.png)
Die Startseite zeigt genau eine Woche: das Thema, was diese Woche zu tun ist,
was schon erledigt ist, was drängt. Kein Dashboard mit zwölf Kacheln – die
Frage lautet „Was mache ich jetzt?", nicht „Wie ist meine Gesamtlage?".
---
### 10. Handouts lesen und suchen
![Handouts](bilder/11-handouts-liste.png)
Die Handouts sind der Lesestoff: kurz, mit Beispielen, jeweils einem Thema
zugeordnet. Die Suche geht über Titel und Volltext.
![Suche](bilder/12-handouts-suche.png)
![Ein Handout](bilder/13-handout-n3.png)
Unten markierst du ein Handout als gelesen. Das ist keine Spielerei: In der
Auswertung zählen gelesene Handouts als **stützende** Nachweise für die
Rahmenplanabdeckung.
Fachbegriffe, die dir unterwegs begegnen, stehen im **Glossar** – kurz erklärt,
mit Querverweisen.
![Glossar](bilder/15-glossar.png)
---
### 11. Merkkarten lernen
![Merkkarten](bilder/14-merkkarten.png)
Die Karten gehören zu den Handouts und kommen nach dem Prinzip der
**Wiederholung nach Zeitabstand** wieder: Was sitzt, kommt später wieder; was
nicht sitzt, heute noch einmal.
Im Lernmodus:
- **Antippen** oder **Leertaste** – Karte umdrehen
- **1 / 2 / 3** – nochmal / unsicher / sitzt
- **Wischen** nach rechts für „sitzt", nach links für „nochmal"
Zehn Minuten am Tag reichen. Wer das durchhält, hat vor der Prüfung nichts
nachzuholen.
---
### 12. Eine Praxisaufgabe abgeben
![Aufgaben](bilder/16-aufgaben-liste.png)
![Eine Aufgabe](bilder/17-aufgabe-azubi.png)
Du liest den Auftrag, siehst die Abnahmekriterien vorher – du weißt also, woran
du gemessen wirst –, schreibst deine Lösung und hängst an, was dazugehört.
Fotos vom Handy gehen direkt.
Dann gibst du ab. Der Ausbilder nimmt ab oder gibt zurück, mit Bemerkung.
---
### 13. Eine Checkliste abarbeiten
![Checklisten](bilder/18-checklisten.png)
Checklisten sind für Abläufe, die immer gleich gehen: einen Arbeitsplatz
übergeben, Windows installieren, einen Rechner zerlegen. Du startest einen
Durchlauf und hakst Schritt für Schritt ab.
![Ein Durchlauf](bilder/19-checklisten-lauf.png)
Ein Durchlauf **kopiert die Schritte bei seinem Start**. Ändert jemand die
Checkliste, während du sie abarbeitest, bleibt dein Durchlauf so, wie du ihn
begonnen hast. Am Ende gibt es ein PDF – ein Beispiel liegt unter
[`docs/beispiel-checkliste.pdf`](beispiel-checkliste.pdf).
---
### 14. Das Berichtsheft führen
![Berichtsheft](bilder/21-berichtsheft.png)
Pro Kalenderwoche ein Blatt, gegliedert nach Tagen, mit Stunden. Die Anwendung
hilft an drei Stellen:
- **Vorschläge** aus dem, was du in der Woche tatsächlich gemacht hast:
abgenommene Aufgaben, abgeschlossene Checklisten, gelesene Handouts.
- **Prüfung vor dem Abgeben**: leere Tage, Floskeln („diverse Tätigkeiten",
„wie immer"), verdächtige Namen von Firmen oder Personen. Der
Ausbildungsnachweis geht an die IHK – da haben Kundennamen nichts verloren.
- **Formulierungshilfe**: aus „Drucker repariert" wird „Fehlerbild aufgenommen,
Fixiereinheit getauscht, Testdruck geprüft, Ticket dokumentiert".
Nach dem Abzeichnen ist das Blatt unveränderlich.
---
### 15. Eine Lernzielkontrolle schreiben
Der Ausbilder gibt die Kontrolle frei, du siehst sie unter *Kontrollen*. Du
schreibst deine Antworten und sendest ab.
**Erst nach dem Absenden** siehst du die Musterlösung – vorher nicht, und auch
nicht über Umwege. Das ist keine Gängelei, sondern der Sinn der Sache: Eine
Kontrolle, deren Lösung vorher abrufbar wäre, misst nichts.
---
## Überall
### 16. Auf dem Handy
Die Anwendung ist für das Handy gebaut, nicht dafür angepasst. Der Azubi hat es
in der Tasche, nicht den Laptop.
| Handout | Lernmodus, Frage | Lernmodus, Antwort |
|---|---|---|
| ![Handout auf dem Handy](bilder/30-handy-handout.png) | ![Frage](bilder/31-handy-lernmodus-frage.png) | ![Antwort](bilder/32-handy-lernmodus-antwort.png) |
**Auf den Startbildschirm legen:** Die Lern-Bar ist eine installierbare
Anwendung.
- *Android (Chrome):* Menü → **App installieren**
- *iPhone (Safari):* Teilen → **Zum Home-Bildschirm**
Danach startet sie ohne Adresszeile und mit eigenem Symbol.
---
### 17. Ohne Netz
Im Serverraum, im Keller, im Zug gibt es kein Netz. Deshalb:
- **Merkkarten** und **Handouts, die du schon einmal geöffnet hattest**,
funktionieren weiter.
- Deine **Bewertungen werden gemerkt** und gehen ab, sobald du wieder verbunden
bist. Eine schmale Leiste sagt dir, wie viele warten – und meldet sich, wenn
sie angekommen sind.
- Der Tag zählt, an dem du **gelernt** hast, nicht der Tag des Absendens.
Was **nicht** offline liegt: Lernzielkontrollen, Berichtsheft, Bewertungen,
Feedbackgespräche, Anhänge und jedes PDF. Das ist Absicht. Diese Dinge stehen
hinter einer eigenen Rechteprüfung, und was im Browserspeicher liegt, ist an
dieser Prüfung vorbei.
Auf einem geteilten Gerät wird beim Abmelden alles Zwischengespeicherte
weggeräumt.
---
### 18. Hell und dunkel
![Dunkle Fassung](bilder/20-dunkel.png)
Oben rechts in der Kopfzeile schaltest du um. Die Einstellung bleibt auf dem
Gerät. Beim Drucken wird immer hell gedruckt, auch wenn du dunkel eingestellt
hast – sonst käme schwarzes Papier aus dem Drucker.
---
### 19. Drucken
Für die Dokumente, die eine Unterschrift tragen, gibt es **PDFs**:
Wochenblatt, Checklisten-Durchlauf, Feedbackgespräch.
Für alles andere gibt es einen **Druckknopf**: Handout, Glossar, Auswertung. Die
Navigation, Knöpfe und Hinweise werden nicht mitgedruckt, Karten werden nicht
mitten durchgeschnitten, aufklappbare Abschnitte drucken aufgeklappt.
---
### 20. Wenn etwas nicht geht
| Beobachtung | Woran es liegt |
|---|---|
| „Diese Seite gibt es nicht" bei fremden Daten | Kein Fehler, sondern die Rechteprüfung. Ob es zu einer Kennung überhaupt Daten gibt, geht Unbefugte nichts an – deshalb 404 und nicht „kein Zugriff". |
| Die Anwendung startet nicht, nennt aber Datei und Zeile | Ein Inhaltsfehler unter `content/`. Die Meldung sagt genau, wo. `npm run inhalte:pruefen` prüft ohne Start. |
| Ein Anhang lässt sich nicht hochladen | Über 25 MB. Bei einem Reverse Proxy zusätzlich `client_max_body_size 30m` setzen (siehe README). |
| Ein abgezeichnetes Blatt lässt sich nicht ändern | So gewollt. Der Ausbilder kann es freigeben; das wird protokolliert. |
| Offline erscheint „Kein Netz" | Diese Seite lag nicht auf dem Gerät. Merkkarten und schon geöffnete Handouts gehen weiter. |
Bleibt etwas offen: `docker compose logs -f` zeigt, was der Server sagt, und
`/api/gesundheit` antwortet nur dann mit 200, wenn die Datenbank ansprechbar
ist.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+6
View File
@@ -7,6 +7,12 @@ const konfiguration: NextConfig = {
serverExternalPackages: ['better-sqlite3', '@node-rs/argon2', 'pdfkit'],
poweredByHeader: false,
reactStrictMode: true,
experimental: {
// Next.js deckelt Serveraktionen sonst bei 1 MB – damit wäre kein einziges
// Handyfoto durchgekommen. Etwas über der eigenen Grenze von 25 MB, damit
// eine zu große Datei die *eigene* Meldung bekommt und nicht die des Gerüsts.
serverActions: { bodySizeLimit: '30mb' },
},
eslint: { ignoreDuringBuilds: true },
async headers() {
return [
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "lernbar",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration",
"engines": {
@@ -19,7 +19,9 @@
"konto:anlegen": "tsx scripts/konto-anlegen.ts",
"backup": "bash scripts/im-container/sicherung.sh",
"restore": "bash scripts/im-container/wiederherstellen.sh",
"screenshots": "tsx scripts/screenshots.ts"
"screenshots": "tsx scripts/screenshots.ts",
"symbole": "tsx scripts/symbole.ts",
"abnahme": "node scripts/pruefungen/abnahme.mjs"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
+161
View File
@@ -0,0 +1,161 @@
/* eslint-disable */
/**
* Der Dienstarbeiter der Lern-Bar.
*
* Er hat genau eine Aufgabe: Der Azubi soll im Serverraum, im Keller oder im
* Zug weiterlernen können, wo kein Netz ist. Alles andere wäre Ballast.
*
* ─── Was offline liegen darf ──────────────────────────────────────────────
*
* Nur das Lernmaterial: Handouts, Merkkarten, Glossar und die eigene Woche.
*
* **Nicht** zwischengespeichert werden Lernzielkontrollen, Berichtsheft,
* Bewertungen, Feedbackgespräche, Anhänge und jedes PDF. Der Grund ist keine
* Sparsamkeit, sondern eine Zusage aus dem Pflichtenheft: Musterlösungen und
* Bewertungsdaten stehen hinter einer eigenen Prüfung. Was im Browsercache
* liegt, ist an dieser Prüfung vorbei – also liegt es dort nicht.
*
* ─── Wie zwischengespeichert wird ─────────────────────────────────────────
*
* Seiten: erst das Netz, dann der Speicher. So sieht man nach einer
* Aktualisierung sofort die neue Fassung und nur bei fehlendem Netz die alte.
*
* Dateien unter /_next/static/: erst der Speicher. Die Namen tragen eine
* Prüfsumme, ihr Inhalt ändert sich nie.
*
* Alles andere – Serveraktionen, Anmeldung, POST – geht immer ans Netz.
*/
const FASSUNG = 'v1';
const SCHALE = `lernbar-schale-${FASSUNG}`;
const SEITEN = `lernbar-seiten-${FASSUNG}`;
const DATEIEN = `lernbar-dateien-${FASSUNG}`;
/** Was schon beim Einrichten vorliegen muss. */
const GRUNDVORRAT = ['/offline', '/symbol-192.png', '/lernbar.svg', '/manifest.webmanifest'];
/** Höchstens so viele Seiten liegen im Speicher – ein Handy ist kein Archiv. */
const HOECHSTZAHL_SEITEN = 60;
/** Nur diese Pfade dürfen offline vorliegen. Alles andere niemals. */
function darfOfflineLiegen(pfad) {
if (pfad === '/' || pfad === '/offline' || pfad === '/glossar') return true;
if (pfad === '/handouts' || pfad.startsWith('/handouts/')) return true;
if (pfad === '/karten' || pfad.startsWith('/karten/')) return true;
if (/^\/woche\/\d+$/.test(pfad)) return true;
return false;
}
self.addEventListener('install', (ereignis) => {
ereignis.waitUntil(
caches
.open(SCHALE)
// Einzeln, damit ein fehlendes Stück nicht die ganze Einrichtung kippt.
.then((speicher) => Promise.all(GRUNDVORRAT.map((pfad) => speicher.add(pfad).catch(() => undefined))))
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (ereignis) => {
ereignis.waitUntil(
caches
.keys()
.then((namen) =>
Promise.all(
namen
.filter((name) => name.startsWith('lernbar-') && ![SCHALE, SEITEN, DATEIEN].includes(name))
.map((name) => caches.delete(name)),
),
)
.then(() => self.clients.claim()),
);
});
/**
* Beim Abmelden wird alles Persönliche weggeräumt.
*
* Ein geteiltes Werkstattgerät soll nach dem Abmelden nicht noch die Woche des
* vorigen Azubis im Speicher haben.
*/
self.addEventListener('message', (ereignis) => {
if (ereignis.data && ereignis.data.typ === 'aufraeumen') {
ereignis.waitUntil(Promise.all([caches.delete(SEITEN), caches.delete(DATEIEN)]));
}
});
async function speicherKuerzen(name, hoechstzahl) {
const speicher = await caches.open(name);
const schluessel = await speicher.keys();
// Die ältesten zuerst – `keys()` liefert sie in der Reihenfolge des Ablegens.
for (const alt of schluessel.slice(0, Math.max(0, schluessel.length - hoechstzahl))) {
await speicher.delete(alt);
}
}
/** Seiten: erst das Netz, bei Ausfall der Speicher, sonst die Offlineseite. */
async function seiteHolen(anfrage) {
const pfad = new URL(anfrage.url).pathname;
try {
const antwort = await fetch(anfrage);
if (antwort.ok && darfOfflineLiegen(pfad)) {
const speicher = await caches.open(SEITEN);
await speicher.put(anfrage, antwort.clone());
await speicherKuerzen(SEITEN, HOECHSTZAHL_SEITEN);
}
return antwort;
} catch (fehler) {
const gespeichert = await caches.match(anfrage, { ignoreSearch: false });
if (gespeichert) return gespeichert;
const offline = await caches.match('/offline');
if (offline) return offline;
return new Response('Kein Netz und nichts im Speicher.', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
}
/** Unveränderliche Dateien: erst der Speicher. */
async function dateiHolen(anfrage) {
const gespeichert = await caches.match(anfrage);
if (gespeichert) return gespeichert;
const antwort = await fetch(anfrage);
if (antwort.ok) {
const speicher = await caches.open(DATEIEN);
await speicher.put(anfrage, antwort.clone());
}
return antwort;
}
self.addEventListener('fetch', (ereignis) => {
const anfrage = ereignis.request;
// Nur eigene Adressen, nur Lesezugriffe.
if (anfrage.method !== 'GET') return;
const adresse = new URL(anfrage.url);
if (adresse.origin !== self.location.origin) return;
// Serveraktionen und Datenschnittstellen gehen immer ans Netz – eine
// zwischengespeicherte Antwort wäre hier schlicht falsch.
if (adresse.pathname.startsWith('/api/') || anfrage.headers.has('Next-Action')) return;
// Auch die Teilnachladungen von Next (RSC) nicht: Sie gehören zu einer
// Sitzung und sollen nicht als Seite verwechselt werden.
if (adresse.searchParams.has('_rsc')) return;
if (anfrage.mode === 'navigate') {
ereignis.respondWith(seiteHolen(anfrage));
return;
}
if (adresse.pathname.startsWith('/_next/static/') || adresse.pathname.endsWith('.svg') || adresse.pathname.endsWith('.png')) {
ereignis.respondWith(dateiHolen(anfrage));
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+7 -2
View File
@@ -46,7 +46,12 @@ Inhalt: lernbar.sqlite (Datenbank), anhaenge/ (hochgeladene Dateien)
Zurückspielen: wiederherstellen.sh <diese-datei.tar.gz>
ENDE
ARCHIV="$ZIEL/lernbar-$STEMPEL.tar.gz"
tar -czf "$ARCHIV" -C "$ARBEIT" lernbar.sqlite anhaenge sicherung.txt
# Bewusst erst ins Zielverzeichnis wechseln und dann einen blossen Dateinamen
# uebergeben: tar deutet einen Doppelpunkt im Dateinamen als Rechnernamen
# ("C:\..." wird zu Rechner "C"). Das faellt nur auf Windows auf, aber ein
# Pfad mit Doppelpunkt kann ueberall vorkommen.
NAME="lernbar-$STEMPEL.tar.gz"
ARCHIV="$ZIEL/$NAME"
( cd "$ZIEL" && tar -czf "$NAME" -C "$ARBEIT" lernbar.sqlite anhaenge sicherung.txt )
echo "$ARCHIV"
+4 -1
View File
@@ -36,7 +36,10 @@ fi
ARBEIT="$(mktemp -d)"
trap 'rm -rf "$ARBEIT"' EXIT
tar -xzf "$ARCHIV" -C "$ARBEIT"
# Siehe sicherung.sh: tar darf den Doppelpunkt im Pfad nicht zu sehen bekommen.
VERZEICHNIS="$(cd "$(dirname "$ARCHIV")" && pwd)"
DATEI="$(basename "$ARCHIV")"
( cd "$VERZEICHNIS" && tar -xzf "$DATEI" -C "$ARBEIT" )
if [ ! -f "$ARBEIT/lernbar.sqlite" ]; then
echo "Das Archiv enthält keine lernbar.sqlite – falsche Datei?" >&2
+1
View File
@@ -30,6 +30,7 @@ function main() {
console.log(` Glossareinträge ${alle.glossar.length}`);
console.log(` Berufsbildpositionen ${alle.positionen.length}`);
console.log(` Lernfelder ${alle.lernfelder.length}`);
console.log(` Feedbackkriterien ${alle.feedback.kriterien.length}`);
console.log('');
const ohneAbdeckung = alle.positionen.filter(
+496
View File
@@ -0,0 +1,496 @@
import { chromium } from '@playwright/test';
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
/**
* Der Abnahmelauf.
*
* Anders als die Prüfungen der einzelnen Bausteine läuft dieser hier gegen
* eine **frische, leere Datenbank** in einem Wegwerfverzeichnis. Er beweist
* damit nicht nur, dass die Anwendung funktioniert, sondern dass sie sich aus
* dem Nichts aufsetzen lässt: Migrationen laufen an, das erste Ausbilderkonto
* entsteht auf der Kommandozeile, alles Weitere über die Oberfläche.
*
* Am Ende wird gesichert, absichtlich etwas kaputtgemacht und zurückgespielt.
* Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung.
*
* node scripts/pruefungen/abnahme.mjs
*
* Voraussetzung: `npm run build` ist gelaufen.
*/
const PORT = Number(process.env.LERNBAR_ABNAHME_PORT ?? 3399);
const BASIS = `http://localhost:${PORT}`;
const AUSBILDER = { name: 'Petra Kessler', email: 'petra.kessler@betrieb.test', pw: 'abnahme-ausbilder' };
const AZUBI = { name: 'Tim Vogt', email: 'tim.vogt@betrieb.test', pw: 'abnahme-azubi-2026' };
let fehler = 0;
const protokoll = [];
function pruefe(bedingung, text, zusatz = '') {
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
protokoll.push({ erfuellt: Boolean(bedingung), text, zusatz });
if (!bedingung) fehler += 1;
}
function abschnitt(titel) {
console.log(`\n${titel}`);
protokoll.push({ abschnitt: titel });
}
// --- Wegwerfumgebung -------------------------------------------------------
const daten = fs.mkdtempSync(path.join(os.tmpdir(), 'lernbar-abnahme-'));
const umgebung = { ...process.env, LERNBAR_DATEN: daten, PORT: String(PORT), TZ: 'Europe/Berlin' };
console.log(`Wegwerfverzeichnis: ${daten}`);
let server = null;
let browser = null;
/**
* Den Server beenden – wirklich.
*
* Unter Windows beendet `kill()` auf einem über die Eingabeaufforderung
* gestarteten Prozess nur diese, nicht den Node-Prozess darunter. Deshalb wird
* hier ohne Eingabeaufforderung gestartet und zur Sicherheit der ganze Baum
* abgeräumt. Ein überlebender Server aus einem früheren Lauf ist kein
* Schönheitsfehler: Er belegt den Port, der nächste Lauf bekommt ihn nicht,
* und dann prüft man stundenlang gegen den alten Datenbestand.
*/
function serverBeenden() {
if (!server) return;
try {
if (process.platform === 'win32') {
spawnSync('taskkill', ['/PID', String(server.pid), '/T', '/F'], { stdio: 'ignore' });
} else {
server.kill('SIGTERM');
}
} catch {
/* egal */
}
server = null;
}
function aufraeumen() {
serverBeenden();
try {
fs.rmSync(daten, { recursive: true, force: true });
} catch {
// Unter Windows hält der Serverprozess die Datei manchmal noch kurz.
}
}
process.on('exit', aufraeumen);
/** Antwortet dort schon jemand? Dann ist es nicht unser Server. */
async function portBelegt() {
try {
await fetch(`${BASIS}/api/gesundheit`, { signal: AbortSignal.timeout(2000) });
return true;
} catch {
return false;
}
}
/** Wartet, bis der Server antwortet. */
async function serverBereit(sekunden = 180) {
for (let versuch = 0; versuch < sekunden * 2; versuch += 1) {
if (server?.exitCode !== null && server?.exitCode !== undefined) return false;
try {
const antwort = await fetch(`${BASIS}/api/gesundheit`);
if (antwort.ok) return true;
} catch {
/* noch nicht da */
}
await new Promise((weiter) => setTimeout(weiter, 500));
}
return false;
}
function laufen(befehl, argumente, zusatz = {}) {
return spawnSync(befehl, argumente, {
env: umgebung,
encoding: 'utf8',
shell: process.platform === 'win32',
...zusatz,
});
}
try {
// --- A1 Aufsetzen aus dem Nichts -----------------------------------------
abschnitt('A1 · Aus dem Nichts aufsetzbar');
if (await portBelegt()) {
throw new Error(
`Auf ${BASIS} antwortet bereits etwas. Dieser Lauf braucht den Port für sich – ` +
'sonst prüft er gegen einen fremden Datenbestand. Bitte beenden oder ' +
'LERNBAR_ABNAHME_PORT setzen.',
);
}
const konto = laufen('npx', [
'tsx', 'scripts/konto-anlegen.ts',
'--name', AUSBILDER.name,
'--email', AUSBILDER.email,
'--rolle', 'ausbilder',
'--passwort', AUSBILDER.pw,
]);
pruefe(konto.status === 0, 'Das erste Ausbilderkonto entsteht auf der Kommandozeile', (konto.stderr || '').trim().split('\n')[0] || 'ohne Fehler');
pruefe(
fs.existsSync(path.join(daten, 'lernbar.sqlite')),
'Die Datenbank wurde angelegt und die Migrationen liefen an',
);
// Ohne Eingabeaufforderung starten: nur dann lässt sich der Prozess später
// auch wirklich beenden.
server = spawn(process.execPath, [path.join('node_modules', 'next', 'dist', 'bin', 'next'), 'start', '-p', String(PORT)], {
env: umgebung,
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverAusgabe = '';
server.stdout.on('data', (stueck) => (serverAusgabe += stueck));
server.stderr.on('data', (stueck) => (serverAusgabe += stueck));
const laeuft = await serverBereit();
pruefe(laeuft, 'Die Anwendung startet und meldet sich gesund', laeuft ? '' : serverAusgabe.slice(-400));
if (!laeuft) throw new Error('Server nicht erreichbar.');
// Die Zusage lautet: Bei einem Inhaltsfehler startet die Anwendung nicht und
// nennt Datei und Zeile. Das prüft man, indem man einen Fehler einbaut –
// nicht, indem man in der Startausgabe nach einem Wort sucht.
const heil = laufen('npx', ['tsx', 'scripts/inhalte-pruefen.ts']);
pruefe(
heil.status === 0,
'Heile Inhalte werden ohne Beanstandung gelesen',
((heil.stdout || '').match(/Inhalte in Ordnung/) ?? ['—'])[0],
);
const kaputteInhalte = path.join(daten, 'kaputte-inhalte');
fs.cpSync(path.join(process.cwd(), 'content'), kaputteInhalte, { recursive: true });
// Ein Verweis auf eine Rahmenplanposition, die es nicht gibt. Das ist der
// Fehler, der ohne Prüfung erst Monate später auffiele – in der Auswertung.
const opfer = path.join(kaputteInhalte, 'handouts', 'N3-subnetting-rechnen.md');
const zeilen = fs.readFileSync(opfer, 'utf8').split(/\r?\n/);
const stelle = zeilen.findIndex((zeile) => zeile.startsWith('rahmenplan:'));
// Wohlgeformt, aber ohne Entsprechung – so greift die Querprüfung zwischen
// den Dateien und nicht bloß die Formatprüfung.
zeilen[stelle] = 'rahmenplan: [B2, C9]';
fs.writeFileSync(opfer, zeilen.join('\n'), 'utf8');
const erwarteteZeile = stelle + 1;
const kaputt = spawnSync('npx', ['tsx', 'scripts/inhalte-pruefen.ts'], {
env: { ...umgebung, LERNBAR_INHALTE: kaputteInhalte },
encoding: 'utf8',
shell: process.platform === 'win32',
});
const meldung = `${kaputt.stdout ?? ''}${kaputt.stderr ?? ''}`;
pruefe(kaputt.status !== 0, 'Ein Inhaltsfehler lässt die Prüfung fehlschlagen', `Ende ${kaputt.status}`);
pruefe(
/N3-subnetting-rechnen\.md/.test(meldung),
'Die Meldung nennt die Datei',
(meldung.match(/.*N3-subnetting-rechnen\.md.*/) ?? ['—'])[0].trim().slice(0, 100),
);
pruefe(
meldung.includes(`N3-subnetting-rechnen.md:${erwarteteZeile}`),
'Die Meldung nennt die richtige Zeile',
`erwartet Zeile ${erwarteteZeile}`,
);
pruefe(meldung.includes('C9'), 'Die Meldung nennt den fehlerhaften Wert');
pruefe(meldung.includes('rahmenplan.yaml'), 'Die Meldung sagt, wo die gültigen Werte stehen');
browser = await chromium.launch();
// --- A2 Zwei Rollen, keine Selbstregistrierung ---------------------------
abschnitt('A2 · Zwei Rollen, keine Selbstregistrierung');
const gast = await browser.newContext({ locale: 'de-DE' });
const gastSeite = await gast.newPage();
gastSeite.setDefaultTimeout(60000);
await gastSeite.goto(`${BASIS}/anmelden`);
const anmeldetext = await gastSeite.locator('body').innerText();
pruefe(/keine Selbstregistrierung/i.test(anmeldetext), 'Die Anmeldeseite bietet keine Registrierung');
const ungeschuetzt = await gast.request.get(`${BASIS}/berichtsheft`, { maxRedirects: 0 });
pruefe(
ungeschuetzt.status() >= 300 && ungeschuetzt.status() < 400,
'Ohne Anmeldung führt jeder Pfad zur Anmeldung',
`HTTP ${ungeschuetzt.status()}`,
);
await gast.close();
const ausbilder = await browser.newContext({ locale: 'de-DE' });
const a = await ausbilder.newPage();
a.setDefaultTimeout(60000);
await a.goto(`${BASIS}/anmelden`);
await a.fill('#email', AUSBILDER.email);
await a.fill('#passwort', AUSBILDER.pw);
await a.click('button[type=submit]');
await a.waitForURL(`${BASIS}/`);
pruefe(true, 'Der Ausbilder meldet sich an');
// --- A3 Der Ausbilder legt ein Azubikonto an -----------------------------
abschnitt('A3 · Der Ausbilder legt Konten an');
await a.goto(`${BASIS}/verwaltung`);
await a.waitForSelector('#neu-name');
await a.fill('#neu-name', AZUBI.name);
await a.fill('#neu-email', AZUBI.email);
await a.fill('#neu-passwort', AZUBI.pw);
await a.fill('#neu-beginn', '2026-08-03');
await a.click('form:has(#neu-name) button[type=submit]');
await a.waitForSelector(`text=${AZUBI.name}`, { timeout: 20000 });
const verwaltungstext = await a.locator('body').innerText();
pruefe(verwaltungstext.includes(AZUBI.name), 'Der Azubi steht in der Verwaltung');
// Das Passwort darf nirgends wieder auftauchen – weder in der Ansicht noch
// in der Datenbank.
await a.reload();
const nachNeuladen = await a.content();
pruefe(!nachNeuladen.includes(AZUBI.pw), 'Das Passwort ist in der Oberfläche nirgends mehr zu sehen');
const { default: Sqlite } = await import('better-sqlite3');
// Bewusst **nicht** readonly: Eine Lesesitzung auf einer WAL-Datenbank sieht
// ohne Schreibrecht auf die Begleitdateien unter Umständen nur den letzten
// Prüfpunkt – und damit ein frisch angelegtes Konto noch nicht.
const pruefDb = new Sqlite(path.join(daten, 'lernbar.sqlite'));
const alleKonten = pruefDb.prepare('select email, rolle from benutzer').all();
const gespeichert = pruefDb.prepare('select passwort_hash from benutzer where email = ?').get(AZUBI.email);
pruefDb.close();
if (!gespeichert) {
console.log(` (in der Datenbank stehen: ${alleKonten.map((k) => `${k.email} [${k.rolle}]`).join(', ') || 'keine'})`);
}
pruefe(
Boolean(gespeichert) && !String(gespeichert.passwort_hash).includes(AZUBI.pw),
'In der Datenbank steht kein Klartextpasswort',
);
pruefe(
String(gespeichert?.passwort_hash ?? '').startsWith('$argon2id$'),
'Das Passwort liegt als Argon2id-Hash',
String(gespeichert?.passwort_hash ?? '').slice(0, 30),
);
// --- A4 Der Azubi arbeitet ------------------------------------------------
abschnitt('A4 · Der Azubi arbeitet');
const azubi = await browser.newContext({ locale: 'de-DE' });
const z = await azubi.newPage();
z.setDefaultTimeout(60000);
await z.goto(`${BASIS}/anmelden`);
await z.fill('#email', AZUBI.email);
await z.fill('#passwort', AZUBI.pw);
await z.click('button[type=submit]');
const angemeldet = await z.waitForURL(`${BASIS}/`, { timeout: 15000 }).then(() => true, () => false);
pruefe(angemeldet, 'Der Azubi meldet sich mit dem vergebenen Passwort an');
if (!angemeldet) throw new Error('Anmeldung des Azubis fehlgeschlagen.');
await z.goto(`${BASIS}/handouts/N3`);
await z.waitForSelector('h1');
await z.click('button:has-text("Als gelesen markieren")');
await z.waitForSelector('button:has-text("Doch noch nicht gelesen")', { timeout: 15000 });
pruefe(true, 'Ein Handout lässt sich als gelesen markieren');
await z.goto(`${BASIS}/karten/lernen`);
const hatKarte = (await z.locator('.lernkarte').count()) > 0;
if (hatKarte) {
await z.click('.lernkarte');
await z.click('.bewerten-sitzt');
await z.waitForTimeout(600);
const rueck = await z.locator('.lernmodus-rueckmeldung').innerText();
pruefe(/kommt/.test(rueck), 'Eine Merkkarte lässt sich bewerten', rueck.trim());
} else {
pruefe(false, 'Eine Merkkarte lässt sich bewerten', 'kein Stapel fällig');
}
// --- A5 Aufgabe abgeben und abnehmen -------------------------------------
abschnitt('A5 · Aufgabe abgeben und abnehmen');
await z.goto(`${BASIS}/aufgaben/1.1`);
await z.waitForSelector('#aufgaben-notiz');
await z.fill(
'#aufgaben-notiz',
'Arbeitsplatz nach Checkliste übergeben: Gerät angeschlossen, Anmeldung geprüft, ' +
'Drucker verbunden, Nutzer eingewiesen, Ticket geschlossen.',
);
await z.click('button:has-text("Notizen speichern")');
await z.waitForSelector('text=/gespeichert/i', { timeout: 20000 });
pruefe(true, 'Der Azubi hält seine Arbeit in Notizen fest');
await z.click('button:has-text("Zur Abnahme")');
await z.waitForTimeout(1500);
await z.reload();
pruefe(
await z.isDisabled('button:has-text("Zur Abnahme")'),
'Der Azubi meldet die Aufgabe zur Abnahme',
);
// Der Ausbilder nimmt ab.
await a.goto(`${BASIS}/aufgaben`);
await a.waitForSelector('h1');
const zurAbnahme = await a.$('a[href*="/aufgaben/1.1?azubi="]');
pruefe(Boolean(zurAbnahme), 'Die Aufgabe erscheint beim Ausbilder zur Abnahme');
if (zurAbnahme) {
await zurAbnahme.click();
await a.waitForSelector('.kriterien', { timeout: 15000 });
for (const kasten of await a.$$('.kriterien input[type=checkbox]')) await kasten.check();
await a.fill('#bewertung', 'gut');
await a.fill('#rueckmeldung', 'Sauber dokumentiert, Einweisung nachvollziehbar.');
await a.click('button:has-text("Bewertung speichern")');
await a.waitForSelector('text=/gespeichert/i', { timeout: 20000 });
await a.click('button:has-text("Aufgabe abnehmen")');
await a.waitForSelector('button:has-text("Bereits abgenommen")', { timeout: 20000 });
pruefe(true, 'Der Ausbilder nimmt die Aufgabe ab');
// Und danach ist sie für den Azubi gesperrt.
await z.goto(`${BASIS}/aufgaben/1.1`);
await z.waitForSelector('h1');
pruefe(
await z.isDisabled('#aufgaben-notiz'),
'Für den Azubi ist die abgenommene Aufgabe gesperrt',
);
// Eine abgenommene Aufgabe ist der belastbare Nachweis in der Abdeckung.
await a.goto(`${BASIS}/auswertung`);
await a.waitForSelector('h1');
const abdeckung = await a.locator('body').innerText();
pruefe(/vermittelt/.test(abdeckung), 'Die Abnahme schlägt in der Rahmenplanabdeckung durch');
pruefe(/ohne Inhalt|kein Inhalt/i.test(abdeckung), 'Lücken im Lehrgang werden getrennt ausgewiesen');
}
// --- A6 Rechte -----------------------------------------------------------
abschnitt('A6 · Rechte serverseitig');
const fremd = await azubi.request.get(`${BASIS}/verwaltung`, { maxRedirects: 0 });
pruefe(
fremd.status() >= 300 && fremd.status() < 400,
'Der Azubi kommt nicht in die Verwaltung',
`HTTP ${fremd.status()}`,
);
// --- A7 Berichtsheft -----------------------------------------------------
abschnitt('A7 · Berichtsheft');
await z.goto(`${BASIS}/berichtsheft`);
await z.waitForSelector('h1');
// Auf einer frischen Datenbank gibt es noch kein Blatt – der Azubi legt das
// der laufenden Woche an. Genau so fängt er im Betrieb auch an.
const anlegen = await z.$('button:has-text("Blatt für diese Woche anlegen")');
if (anlegen) {
await anlegen.click();
await z.waitForSelector('.blattzeile-link, h1', { timeout: 30000 });
await z.goto(`${BASIS}/berichtsheft`);
await z.waitForSelector('h1');
}
pruefe(
(await z.locator('.blattzeile-link').count()) > 0,
'Der Azubi legt das Wochenblatt der laufenden Woche an',
);
const blatt = await z.$('.blattzeile-link');
if (blatt) {
await blatt.click();
// Auf die Adresse warten, nicht auf eine Überschrift: Die steht auch auf
// der Liste, von der wir kommen. Sonst zeigt `url()` noch dorthin – und
// die Anfrage landet auf der Sammelroute statt auf dem einzelnen Blatt.
await z.waitForURL(/\/berichtsheft\/[0-9a-f-]{36}$/, { timeout: 30000 });
pruefe(true, 'Ein Wochenblatt lässt sich öffnen');
const pdf = await azubi.request.get(`${z.url()}/pdf`);
const puffer = await pdf.body();
pruefe(
pdf.status() === 200 && puffer.subarray(0, 5).toString() === '%PDF-',
'Das Wochenblatt kommt als PDF heraus',
`HTTP ${pdf.status()}, ${puffer.byteLength} Bytes${
puffer.subarray(0, 5).toString() === '%PDF-' ? '' : `: „${puffer.toString('utf8').slice(0, 60)}"`
}`,
);
} else {
pruefe(false, 'Ein Wochenblatt lässt sich öffnen', 'keine Blattzeile gefunden');
}
// --- A8 Sicherung und Rückspielung ---------------------------------------
abschnitt('A8 · Sicherung zurückspielen');
// Pfade für bash mit Schrägstrichen: Ein Backslash ist dort ein Fluchtzeichen.
const alsPosix = (pfad) => pfad.split(path.sep).join('/');
const sicherung = laufen('bash', ['scripts/im-container/sicherung.sh', alsPosix(path.join(daten, 'sicherungen'))]);
const archiv = (sicherung.stdout || '').trim().split('\n').pop();
pruefe(
sicherung.status === 0 && archiv && fs.existsSync(archiv),
'Die Sicherung läuft durch',
archiv ? path.basename(archiv) : (sicherung.stderr || '').trim(),
);
// Jetzt absichtlich kaputtmachen: Der Azubi verschwindet. Vorher den Server
// beenden – zurückgespielt wird bei gestoppter Anwendung, so steht es auch
// in der Anleitung.
serverBeenden();
await new Promise((weiter) => setTimeout(weiter, 2500));
const { default: Datenbank } = await import('better-sqlite3');
const dbPfad = path.join(daten, 'lernbar.sqlite');
const vorher = (() => {
const db = new Datenbank(dbPfad);
const zahl = db.prepare('select count(*) n from benutzer').get().n;
db.prepare('delete from benutzer where email = ?').run(AZUBI.email);
const nachher = db.prepare('select count(*) n from benutzer').get().n;
db.close();
return { zahl, nachher };
})();
pruefe(vorher.nachher < vorher.zahl, 'Zum Beweis wird der Datenbestand beschädigt', `${vorher.zahl} → ${vorher.nachher} Konten`);
const zurueck = laufen('bash', ['scripts/im-container/wiederherstellen.sh', alsPosix(archiv ?? ''), '--ohne-rueckfrage']);
pruefe(zurueck.status === 0, 'Die Rückspielung läuft durch', (zurueck.stderr || '').trim() || 'ohne Fehler');
const wiederDa = (() => {
const db = new Datenbank(dbPfad);
const treffer = db.prepare('select name from benutzer where email = ?').get(AZUBI.email);
const karten = db.prepare('select count(*) n from karten_verlauf').get().n;
db.close();
return { treffer, karten };
})();
pruefe(wiederDa.treffer?.name === AZUBI.name, 'Der gelöschte Azubi ist wieder da', wiederDa.treffer?.name ?? 'fehlt');
pruefe(wiederDa.karten >= 0, 'Auch der Lernverlauf kam mit zurück', `${wiederDa.karten} Einträge`);
const verdraengt = fs.readdirSync(daten).filter((name) => name.startsWith('verdraengt-'));
pruefe(verdraengt.length === 1, 'Der überschriebene Stand wurde zur Seite gelegt, nicht gelöscht', verdraengt[0] ?? '—');
} catch (ausnahme) {
console.error('\nAbbruch:', ausnahme instanceof Error ? ausnahme.message : ausnahme);
fehler += 1;
} finally {
await browser?.close();
}
// --- Protokoll -------------------------------------------------------------
const zeilen = ['# Abnahmeprotokoll', '', `Erzeugt am ${new Date().toISOString().slice(0, 16).replace('T', ' ')} Uhr`, '', 'Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenbank**.', ''];
for (const eintrag of protokoll) {
if (eintrag.abschnitt) zeilen.push('', `## ${eintrag.abschnitt}`, '');
else zeilen.push(`- ${eintrag.erfuellt ? '✓' : '✗'} ${eintrag.text}${eintrag.zusatz ? ` — ${eintrag.zusatz}` : ''}`);
}
zeilen.push('', `**${protokoll.filter((e) => e.erfuellt).length} von ${protokoll.filter((e) => 'erfuellt' in e).length} Prüfungen erfüllt.**`, '');
fs.writeFileSync(path.join(process.cwd(), 'docs', 'ABNAHME.md'), zeilen.join('\n'), 'utf8');
console.log('\nProtokoll: docs/ABNAHME.md');
console.log(`${fehler === 0 ? '✓ Abnahme bestanden.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
process.exit(fehler === 0 ? 0 : 1);
+292
View File
@@ -0,0 +1,292 @@
import { chromium } from '@playwright/test';
/**
* Durchstich für Baustein 10 – PWA, Offlinebetrieb, Druck.
*
* Die Zusage dieses Bausteins ist schmal und genau: Der Azubi soll ohne Netz
* weiterlernen können, und was er dabei bewertet, darf nicht verloren gehen.
* Gleichzeitig darf **nichts** offline liegen, was hinter einer eigenen
* Rechteprüfung steht – Musterlösungen und Bewertungen gehören nicht in den
* Browserspeicher.
*
* Geprüft wird gegen den laufenden Server, mit echtem Abschalten des Netzes.
*/
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
let fehler = 0;
function pruefe(bedingung, text, zusatz = '') {
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
if (!bedingung) fehler += 1;
}
const browser = await chromium.launch();
const kontext = await browser.newContext({ locale: 'de-DE' });
const seite = await kontext.newPage();
async function anmelden() {
await seite.goto(`${BASIS}/anmelden`);
await seite.fill('#email', AZUBI.email);
await seite.fill('#passwort', AZUBI.pw);
await seite.click('button[type=submit]');
await seite.waitForURL(`${BASIS}/`);
}
/** Wartet, bis der Dienstarbeiter das Ruder übernommen hat. */
async function dienstarbeiterBereit() {
return seite.evaluate(async () => {
if (!('serviceWorker' in navigator)) return false;
const registrierung = await navigator.serviceWorker.ready;
// `ready` sagt nur „installiert". Erst ein Controller fängt Anfragen ab.
if (navigator.serviceWorker.controller) return true;
await new Promise((fertig) => {
navigator.serviceWorker.addEventListener('controllerchange', fertig, { once: true });
window.setTimeout(fertig, 5000);
});
return Boolean(navigator.serviceWorker.controller || registrierung.active);
});
}
// --- 1. Die Anwendung lässt sich installieren ------------------------------
console.log('\n1. Installierbar');
const manifest = await kontext.request.get(`${BASIS}/manifest.webmanifest`);
pruefe(manifest.status() === 200, 'Das Manifest wird ausgeliefert', `HTTP ${manifest.status()}`);
const manifestInhalt = await manifest.json().catch(() => null);
pruefe(manifestInhalt?.name?.includes('Lern-Bar'), 'Es trägt den Namen der Anwendung');
pruefe(manifestInhalt?.display === 'standalone', 'Es startet als eigenständige Anwendung');
pruefe(
Array.isArray(manifestInhalt?.icons) && manifestInhalt.icons.some((s) => s.purpose === 'maskable'),
'Es enthält ein Symbol für den Beschnitt unter Android',
);
for (const datei of ['/symbol-192.png', '/symbol-512.png', '/symbol-maskabel-512.png', '/sw.js']) {
const antwort = await kontext.request.get(`${BASIS}${datei}`);
pruefe(antwort.status() === 200, `${datei} liegt bereit`, `HTTP ${antwort.status()}`);
}
// Nichts davon darf von außen kommen – die Anwendung läuft in einem Netz
// ohne Internet.
await anmelden();
const fremdeQuellen = await seite.evaluate(() =>
[...document.querySelectorAll('script[src], link[href], img[src]')]
.map((element) => element.getAttribute('src') ?? element.getAttribute('href') ?? '')
.filter((adresse) => /^https?:\/\//.test(adresse) && !adresse.startsWith(location.origin)),
);
pruefe(fremdeQuellen.length === 0, 'Keine Quelle von außerhalb', fremdeQuellen.join(', ') || 'keine');
// --- 2. Der Dienstarbeiter übernimmt ---------------------------------------
console.log('\n2. Dienstarbeiter');
pruefe(await dienstarbeiterBereit(), 'Der Dienstarbeiter ist eingerichtet und steuert die Seite');
// Lernmaterial einmal ansehen, damit es im Speicher landet.
await seite.goto(`${BASIS}/handouts/N3`);
await seite.waitForSelector('h1');
await seite.goto(`${BASIS}/karten/lernen`);
await seite.waitForSelector('h1, .lernkarte');
// Und etwas, das **nicht** in den Speicher darf.
await seite.goto(`${BASIS}/kontrollen`);
await seite.waitForSelector('h1');
const imSpeicher = await seite.evaluate(async () => {
const namen = await caches.keys();
const pfade = [];
for (const name of namen) {
const speicher = await caches.open(name);
for (const anfrage of await speicher.keys()) pfade.push(new URL(anfrage.url).pathname);
}
return pfade;
});
pruefe(imSpeicher.includes('/handouts/N3'), 'Das gelesene Handout liegt im Speicher');
pruefe(imSpeicher.includes('/karten/lernen'), 'Der Lernmodus liegt im Speicher');
// Das ist die eigentliche Zusage – hier wird eine Regel geprüft, keine Laune.
const verboten = imSpeicher.filter((pfad) =>
/^\/(kontrollen|berichtsheft|feedback|auswertung|aufgaben|checklisten|verwaltung|anhaenge)/.test(pfad),
);
pruefe(
verboten.length === 0,
'Nichts Bewertungsrelevantes liegt im Browserspeicher',
verboten.join(', ') || 'nichts',
);
pruefe(
!imSpeicher.some((pfad) => pfad.endsWith('/pdf')),
'Kein PDF liegt im Browserspeicher',
);
// --- 3. Ohne Netz --------------------------------------------------------
console.log('\n3. Ohne Netz');
await kontext.setOffline(true);
const handoutOffline = await seite.goto(`${BASIS}/handouts/N3`).catch(() => null);
pruefe(handoutOffline !== null && handoutOffline.ok(), 'Das gelesene Handout öffnet sich ohne Netz');
pruefe(
(await seite.locator('h1').innerText().catch(() => '')).length > 0,
'Es ist wirklich der Inhalt und keine Fehlerseite',
await seite.locator('h1').innerText().catch(() => '—'),
);
const lernenOffline = await seite.goto(`${BASIS}/karten/lernen`).catch(() => null);
pruefe(lernenOffline !== null && lernenOffline.ok(), 'Der Lernmodus öffnet sich ohne Netz');
// Die Netzleiste sagt Bescheid.
await seite.waitForTimeout(500);
const leiste = await seite.locator('.netzleiste').count();
pruefe(leiste > 0, 'Die Leiste „Ohne Netz" erscheint');
// Eine Seite, die nie im Speicher war, landet auf der Offlineseite.
const unbekannt = await seite.goto(`${BASIS}/kontrollen`).catch(() => null);
const unbekannterText = await seite.locator('body').innerText().catch(() => '');
pruefe(
unbekannterText.includes('Kein Netz'),
'Eine nicht gespeicherte Seite zeigt die Offlineseite',
`HTTP ${unbekannt?.status() ?? '—'}`,
);
// --- 4. Bewertungen gehen nicht verloren -----------------------------------
console.log('\n4. Bewertungen ohne Netz');
await seite.goto(`${BASIS}/karten/lernen`);
const hatKarte = (await seite.locator('.lernkarte').count()) > 0;
if (!hatKarte) {
console.log(' – heute ist keine Karte fällig, Warteschlange übersprungen');
} else {
await seite.click('.lernkarte');
await seite.waitForSelector('.bewerten-knopf');
await seite.click('.bewerten-sitzt');
await seite.waitForTimeout(800);
const schlange = await seite.evaluate(() =>
Object.keys(localStorage)
.filter((s) => s.startsWith('lernbar-warteschlange-'))
.flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')),
);
pruefe(schlange.length >= 1, 'Die Bewertung liegt in der Warteschlange', `${schlange.length} Eintrag/Einträge`);
pruefe(
schlange.every((e) => e.karteId && e.bewertung && /^\d{4}-\d{2}-\d{2}$/.test(e.erfasstAm)),
'Jeder Eintrag trägt Karte, Bewertung und den Tag der Bewertung',
);
const gemeldet = await seite.locator('.lernmodus-rueckmeldung').innerText();
pruefe(/gemerkt/.test(gemeldet), 'Der Azubi sieht, dass die Bewertung gemerkt wurde', gemeldet.trim());
// --- Und wieder im Netz ---
console.log('\n5. Wieder im Netz');
// Wichtig: auf **derselben** Seite bleiben. Die Verbindung kehrt zurück,
// während der Azubi noch im Lernmodus steht – genau dort muss die
// Bestätigung erscheinen. Wer vorher die Seite wechselt, sieht sie nie,
// weil die Schlange dann schon leer ist.
await kontext.setOffline(false);
const bestaetigung = await seite
.locator('.netzleiste')
.filter({ hasText: 'nachgereicht' })
.first()
.innerText({ timeout: 15000 })
.catch(() => '');
pruefe(/nachgereicht/.test(bestaetigung), 'Der Azubi bekommt die Bestätigung zu sehen', bestaetigung.trim() || '—');
const nachher = await seite.evaluate(() =>
Object.keys(localStorage)
.filter((s) => s.startsWith('lernbar-warteschlange-'))
.flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')),
);
pruefe(nachher.length === 0, 'Die Warteschlange ist abgearbeitet', `${nachher.length} übrig`);
// Und die Bewertung ist wirklich angekommen, nicht nur verschwunden.
await seite.goto(`${BASIS}/karten`);
await seite.waitForSelector('h1');
const uebersicht = await seite.locator('body').innerText();
pruefe(
!/0 von \d+ Karten/.test(uebersicht) || /sitzt/i.test(uebersicht),
'Die Übersicht kennt den neuen Lernstand',
);
}
await kontext.setOffline(false);
// --- 6. Die Schnittstelle prüft die Rechte ---------------------------------
console.log('\n6. Rechte an der Schnittstelle');
const ohneAnmeldung = await browser.newContext();
const abgewiesen = await ohneAnmeldung.request.post(`${BASIS}/api/lernstand`, {
data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] },
});
pruefe(abgewiesen.status() === 401, 'Ohne Anmeldung: abgewiesen', `HTTP ${abgewiesen.status()}`);
await ohneAnmeldung.close();
const alsAusbilder = await browser.newContext({ locale: 'de-DE' });
const ausbilderSeite = await alsAusbilder.newPage();
await ausbilderSeite.goto(`${BASIS}/anmelden`);
await ausbilderSeite.fill('#email', 'fw@wildeflorian.de');
await ausbilderSeite.fill('#passwort', 'ausbilder-start');
await ausbilderSeite.click('button[type=submit]');
await ausbilderSeite.waitForURL(`${BASIS}/`);
const alsAusbilderAbgewiesen = await alsAusbilder.request.post(`${BASIS}/api/lernstand`, {
data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] },
});
pruefe(
alsAusbilderAbgewiesen.status() === 403,
'Der Ausbilder darf den Lernstand nicht führen',
`HTTP ${alsAusbilderAbgewiesen.status()}`,
);
await alsAusbilder.close();
// --- 7. Druck --------------------------------------------------------------
console.log('\n7. Druck');
await seite.goto(`${BASIS}/handouts/N3`);
await seite.waitForSelector('h1');
// Die dunkle Fassung einstellen – und dann drucken. Das ist der Fall, in dem
// früher schwarze Flächen aufs Papier gekommen wären.
await seite.evaluate(() => {
document.documentElement.setAttribute('data-thema', 'dunkel');
localStorage.setItem('lernbar-thema', 'dunkel');
});
await seite.emulateMedia({ media: 'print' });
await seite.waitForTimeout(200);
const druckfarben = await seite.evaluate(() => {
const stil = getComputedStyle(document.body);
const wurzel = getComputedStyle(document.documentElement);
return { grund: stil.backgroundColor, text: stil.color, flaeche: wurzel.getPropertyValue('--flaeche').trim() };
});
pruefe(
druckfarben.grund === 'rgb(255, 255, 255)',
'Im Druck ist der Grund weiß, auch bei eingestellter dunkler Fassung',
druckfarben.grund,
);
pruefe(druckfarben.text === 'rgb(0, 0, 0)', 'Der Text ist schwarz', druckfarben.text);
pruefe(
druckfarben.flaeche.toLowerCase() === '#f2f2f2',
'Auch die Flächen sind hell',
druckfarben.flaeche,
);
const versteckt = await seite.evaluate(() => {
const kopf = document.querySelector('.kopf');
return kopf ? getComputedStyle(kopf).display : 'keine';
});
pruefe(versteckt === 'none', 'Die Navigation wird nicht mitgedruckt', versteckt);
await seite.emulateMedia({ media: null });
await browser.close();
console.log(`\n${fehler === 0 ? '✓ Baustein 10 steht.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
process.exit(fehler === 0 ? 0 : 1);
+237
View File
@@ -0,0 +1,237 @@
import { chromium } from '@playwright/test';
/**
* Durchstich für Baustein 9.
*
* Geprüft werden die beiden Zusagen, die in diesem Baustein zählen:
*
* 1. Die Rahmenplanabdeckung unterscheidet vier Lagen – und weist Positionen
* ohne Material im Lehrgang getrennt aus (A4, A10, C1, C2, C5).
* 2. Das Feedbackgespräch bewertet beide Seiten getrennt, hebt Abweichungen
* hervor, ist nach beidseitiger Bestätigung unveränderlich und liefert
* ein PDF.
*/
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
const AUSBILDER = { email: 'fw@wildeflorian.de', pw: 'ausbilder-start' };
let fehler = 0;
function pruefe(bedingung, text, zusatz = '') {
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
if (!bedingung) fehler += 1;
}
const browser = await chromium.launch();
const UEBRIGE = [
'sorgfalt', 'dokumentation', 'fachwissen', 'fehlersuche',
'kundenkontakt', 'zuverlaessigkeit', 'lernbereitschaft',
];
/**
* Die Radios selbst sind für das Auge weggeblendet; angeklickt wird die
* Beschriftung – genau wie ein Mensch es täte.
*/
async function waehle(seite, kriterium, wert) {
await seite.click(`label.stufe:has(input[name="stufe-${kriterium}"][value="${wert}"])`);
}
async function neueSitzung(konto) {
const kontext = await browser.newContext({ locale: 'de-DE' });
const seite = await kontext.newPage();
await seite.goto(`${BASIS}/anmelden`);
await seite.fill('#email', konto.email);
await seite.fill('#passwort', konto.pw);
await seite.click('button[type=submit]');
await seite.waitForURL(`${BASIS}/`);
return { kontext, seite };
}
// --- 1. Auswertung ---------------------------------------------------------
console.log('\n1. Auswertung und Rahmenplanabdeckung (Ausbilder)');
const ausbilder = await neueSitzung(AUSBILDER);
await ausbilder.seite.goto(`${BASIS}/auswertung`);
await ausbilder.seite.waitForLoadState('networkidle');
const text = await ausbilder.seite.locator('body').innerText();
pruefe(text.includes('Abdeckung'), 'Die Auswertung zeigt die Rahmenplanabdeckung');
pruefe(/ohne Inhalt/i.test(text), 'Die vierte Lage „ohne Inhalt" kommt vor');
// Die fünf Lücken im Beispielinhalt müssen als eigene Gruppe auftauchen.
const luecken = ['A4', 'A10', 'C1', 'C2', 'C5'];
const gefunden = [];
for (const kuerzel of luecken) {
const treffer = await ausbilder.seite
.locator(`[data-stufe="ohne_inhalt"]:has-text("${kuerzel}")`)
.count()
.catch(() => 0);
if (treffer > 0) gefunden.push(kuerzel);
}
pruefe(
gefunden.length === luecken.length,
'Alle fünf Positionen ohne Material sind als Lücke im Lehrgang gekennzeichnet',
`gefunden: ${gefunden.join(', ') || 'keine'}`,
);
pruefe(/Lernfeld/i.test(text), 'Die Lernfeldabdeckung ist vorhanden');
pruefe(/§|Verordnung|Abschnitt/i.test(text), 'Die Fundstelle in der Verordnung wird genannt');
// --- 2. Feedbackgespräch anlegen ------------------------------------------
console.log('\n2. Feedbackgespräch – Ausbilder legt an und bewertet');
await ausbilder.seite.goto(`${BASIS}/feedback`);
await ausbilder.seite.click('button:has-text("Neues Gespräch anlegen")');
await ausbilder.seite.waitForURL(/\/feedback\/[0-9a-f-]{36}$/);
const gespraechUrl = ausbilder.seite.url();
const gespraechId = gespraechUrl.split('/').pop();
pruefe(Boolean(gespraechId), 'Gespräch angelegt', gespraechId);
// Ausbilder bewertet: Selbstständigkeit 2, alle anderen 4.
await waehle(ausbilder.seite, 'selbststaendigkeit', 2);
for (const k of UEBRIGE) await waehle(ausbilder.seite, k, 4);
await ausbilder.seite.fill('#staerken', 'Arbeitet ruhig und fragt nach, bevor etwas kaputtgeht.');
await ausbilder.seite.fill('#entwicklungsfelder', 'Bei unklarer Lage zu lange allein – früher melden.');
await ausbilder.seite.fill('#ziele', 'Bis Ende November eine Rücksicherung eigenständig durchführen.');
await ausbilder.seite.click('button:has-text("Speichern")');
await ausbilder.seite.waitForSelector('text=Gespeichert', { timeout: 15000 });
pruefe(true, 'Einschätzung des Ausbilders gespeichert');
// --- 3. Azubi sieht das Gespräch und schätzt sich selbst ein ---------------
console.log('\n3. Feedbackgespräch – Azubi schätzt sich selbst ein');
const azubi = await neueSitzung(AZUBI);
await azubi.seite.goto(gespraechUrl);
await azubi.seite.waitForLoadState('networkidle');
const azubiText = await azubi.seite.locator('body').innerText();
pruefe(azubiText.includes('Feedbackgespräch'), 'Der Azubi sieht sein Gespräch');
pruefe(
azubiText.includes('Arbeitet ruhig und fragt nach'),
'Der Azubi liest die Freitexte des Ausbilders',
);
pruefe(
(await azubi.seite.locator('#staerken').count()) === 0,
'Der Azubi kann die Freitexte nicht bearbeiten',
);
// Azubi schätzt sich bei Selbstständigkeit deutlich höher ein: 5 gegen 2.
await waehle(azubi.seite, 'selbststaendigkeit', 5);
for (const k of UEBRIGE) await waehle(azubi.seite, k, 4);
await azubi.seite.click('button:has-text("Speichern")');
await azubi.seite.waitForSelector('text=Selbsteinschätzung ist gespeichert', { timeout: 15000 });
await azubi.seite.reload();
const nachSpeichern = await azubi.seite.locator('body').innerText();
pruefe(
/Unterschiedliche Sicht/i.test(nachSpeichern),
'Die Abweichung von drei Stufen wird hervorgehoben',
);
// Gegenprobe: Die Spalte des Ausbilders blieb unangetastet.
await ausbilder.seite.reload();
pruefe(
await ausbilder.seite.isChecked('input[name="stufe-selbststaendigkeit"][value="2"]'),
'Der Azubi hat die Einschätzung des Ausbilders nicht überschrieben',
);
// --- 4. Beidseitige Bestätigung sperrt -------------------------------------
console.log('\n4. Bestätigung durch beide Seiten');
await azubi.seite.click('button:has-text("Gespräch bestätigen")');
await azubi.seite.waitForSelector('text=Bestätigt', { timeout: 15000 });
pruefe(true, 'Azubi hat bestätigt');
// Erst ohne, dann mit Neuladen – so ist ein Rennen von einem echten Fehler
// zu unterscheiden.
const sofort = await azubi.seite
.locator('button:has-text("Bestätigung zurücknehmen")')
.waitFor({ timeout: 5000 })
.then(() => true, () => false);
await azubi.seite.reload();
const nachLaden = (await azubi.seite.locator('button:has-text("Bestätigung zurücknehmen")').count()) > 0;
pruefe(
nachLaden,
'Solange nur eine Seite bestätigt hat, lässt sich das zurücknehmen',
sofort ? 'sofort sichtbar' : 'erst nach dem Neuladen sichtbar',
);
await ausbilder.seite.reload();
await ausbilder.seite.click('button:has-text("Als Ausbilder bestätigen")');
await ausbilder.seite.waitForSelector('text=unveränderlich', { timeout: 15000 });
await ausbilder.seite.reload();
const gesperrt = await ausbilder.seite.locator('body').innerText();
pruefe(/abgeschlossen und unveränderlich/.test(gesperrt), 'Das Gespräch ist jetzt abgeschlossen');
pruefe(
(await ausbilder.seite.locator('button:has-text("Speichern")').count()) === 0,
'Es gibt keinen Speichern-Knopf mehr',
);
pruefe(
(await ausbilder.seite.locator('button:has-text("Bestätigung zurücknehmen")').count()) === 0,
'Die Bestätigung lässt sich nicht mehr zurücknehmen',
);
// Serverseitige Gegenprobe: Schreiben trotz Sperre muss abgewiesen werden.
await azubi.seite.reload();
pruefe(
await azubi.seite.isDisabled('input[name="stufe-selbststaendigkeit"][value="5"]'),
'Auch beim Azubi sind die Felder gesperrt',
);
// --- 5. PDF ----------------------------------------------------------------
console.log('\n5. PDF für die Akte');
const pdfAntwort = await ausbilder.kontext.request.get(`${gespraechUrl}/pdf`);
const pdfKoerper = await pdfAntwort.body();
pruefe(pdfAntwort.status() === 200, 'PDF wird ausgeliefert', `HTTP ${pdfAntwort.status()}`);
pruefe(pdfKoerper.subarray(0, 5).toString() === '%PDF-', 'Die Antwort ist wirklich ein PDF');
pruefe(pdfKoerper.byteLength > 3000, 'Das PDF hat Inhalt', `${pdfKoerper.byteLength} Bytes`);
// Und ohne Anmeldung? Ein PDF aus der Ausbildungsakte darf nicht herausfallen.
const fremd = await browser.newContext();
const ohne = await fremd.request.get(`${gespraechUrl}/pdf`, { maxRedirects: 0 });
pruefe(
ohne.status() >= 300 && ohne.status() < 400,
'Ohne Anmeldung kommt kein PDF heraus',
`HTTP ${ohne.status()} → ${ohne.headers()['location'] ?? '—'}`,
);
await fremd.close();
// --- 6. Fremdzugriff -------------------------------------------------------
console.log('\n6. Rechte');
const fremderAzubi = await browser.newContext({ locale: 'de-DE' });
const fremdSeite = await fremderAzubi.newPage();
await fremdSeite.goto(`${BASIS}/anmelden`);
await fremdSeite.fill('#email', 'lea.hoffmann@betrieb.de');
await fremdSeite.fill('#passwort', 'azubi-start-2026');
await fremdSeite.click('button[type=submit]');
const angemeldet = await fremdSeite.waitForURL(`${BASIS}/`, { timeout: 8000 }).then(
() => true,
() => false,
);
if (angemeldet) {
const antwort = await fremdSeite.goto(gespraechUrl);
pruefe(antwort?.status() === 404, 'Ein anderer Azubi bekommt 404', `HTTP ${antwort?.status()}`);
const fremdPdf = await fremderAzubi.request.get(`${gespraechUrl}/pdf`);
pruefe(fremdPdf.status() === 404, 'Auch das PDF bleibt verschlossen', `HTTP ${fremdPdf.status()}`);
} else {
console.log(' – zweites Azubikonto nicht vorhanden, Fremdzugriff übersprungen');
}
await fremderAzubi.close();
await browser.close();
console.log(`\n${fehler === 0 ? '✓ Baustein 9 steht.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
process.exit(fehler === 0 ? 0 : 1);
@@ -0,0 +1,142 @@
import { chromium } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
/**
* Gezielte Prüfung der einen Zusage, auf die es bei den Lernzielkontrollen
* ankommt: Die Musterlösung darf vor dem Absenden nirgends herauskommen.
*
* Geprüft wird gegen den **echten** Wortlaut aus der Inhaltsdatei, nicht gegen
* ausgedachte Zeichenketten – und im rohen Quelltext samt RSC-Nutzlast, nicht
* im sichtbaren Text.
*/
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
const AUSBILDER = { email: 'fw@wildeflorian.de', pw: 'ausbilder-start' };
// Musterlösungen direkt aus der Datei lesen – so kann der Test nicht veralten.
const yaml = fs.readFileSync(path.join(process.cwd(), 'content/lernzielkontrollen/woche-01.yaml'), 'utf8');
const loesungen = [...yaml.matchAll(/musterloesung:\s*>-\s*\n((?:\s{6}.+\n)+)/g)].map((t) =>
t[1].replace(/\s+/g, ' ').trim(),
);
// Aussagekräftige Bruchstücke: lang genug, um nicht zufällig vorzukommen.
const bruchstuecke = loesungen.map((l) => l.slice(40, 110)).filter((b) => b.length > 40);
console.log(`${loesungen.length} Musterlösungen aus woche-01.yaml gelesen.`);
console.log('Geprüft wird auf Bruchstücke wie:', JSON.stringify(bruchstuecke[0]?.slice(0, 60)));
// --- Ausgangslage herstellen ----------------------------------------------
//
// Der entscheidende Fall ist „freigegeben, aber noch nicht abgegeben". Den gibt
// es nur einmal je Kontrolle. Damit die Prüfung wiederholbar bleibt, wird die
// Kontrolle „woche-01" vorher zurückgesetzt – das ist ein Testaufbau, kein Teil
// der Anwendung, und greift deshalb ausdrücklich nur auf die lokale
// Beispieldatenbank zu.
const { default: Datenbank } = await import('better-sqlite3');
const dbPfad = process.env.LERNBAR_DB ?? path.join(process.cwd(), 'daten', 'lernbar.sqlite');
if (fs.existsSync(dbPfad)) {
const db = new Datenbank(dbPfad);
const betroffen = db.prepare("select id from kontrolle where kontrolle_id = 'woche-01'").all();
for (const eintrag of betroffen) {
db.prepare('delete from kontroll_antwort where kontrolle_id = ?').run(eintrag.id);
db.prepare('delete from kontrolle where id = ?').run(eintrag.id);
}
db.close();
console.log(`Ausgangslage hergestellt: ${betroffen.length} frühere Bearbeitung(en) von woche-01 entfernt.`);
} else {
console.log(`Keine Datenbank unter ${dbPfad} – die Ausgangslage wird nicht zurückgesetzt.`);
}
const browser = await chromium.launch();
const seite = await (await browser.newContext({ locale: 'de-DE' })).newPage();
async function anmelden(k) {
await seite.goto(`${BASIS}/anmelden`);
await seite.fill('#email', k.email);
await seite.fill('#passwort', k.pw);
await seite.click('button[type=submit]');
await seite.waitForURL(`${BASIS}/`);
}
async function abmelden() {
await seite.click('button:has-text("Abmelden")');
await seite.waitForURL(/anmelden/);
}
/** Sucht im rohen HTML **und** in der Server-Nutzlast. */
async function lecksuche(beschriftung) {
const html = await seite.content();
// Die Nutzlast steht in eingebetteten Skripten, die `self.__next_f` füllen.
// Dort **nachträglich** nachzusehen bringt nichts: Next leert das Feld, sobald
// es verarbeitet ist – es wäre immer leer, und die Prüfung liefe ins Leere,
// ohne dass es auffällt. Deshalb wird der Quelltext der Skripte gelesen.
const rsc = await seite.evaluate(() =>
[...document.querySelectorAll('script:not([src])')]
.map((element) => element.textContent ?? '')
.filter((text) => text.includes('__next_f'))
.join(''),
);
const treffer = bruchstuecke.filter((b) => html.includes(b) || rsc.includes(b));
const nullFelder = (rsc.match(/musterloesung.{0,12}null/g) ?? []).length;
console.log(
` ${beschriftung.padEnd(34)} Leck: ${treffer.length === 0 ? 'nein' : `JA (${treffer.length})`}` +
` | Nutzlast ${rsc.length} Zeichen | "musterloesung":null ${nullFelder}x`,
);
return treffer;
}
console.log('\n1) Vor der Freigabe');
await anmelden(AZUBI);
await seite.goto(`${BASIS}/kontrollen/woche-01`);
await seite.waitForSelector('h1');
await lecksuche('gesperrt');
console.log('\n2) Ausbilder gibt frei');
await abmelden();
await anmelden(AUSBILDER);
await seite.goto(`${BASIS}/kontrollen`);
await seite.waitForSelector('.kontrollzeile');
const freigeben = seite
.locator('.kontrollzeile', { hasText: 'Woche 1' })
.locator('button:has-text("Freigeben")');
if ((await freigeben.count()) > 0) {
await freigeben.click();
await seite.waitForTimeout(1500);
} else {
// Bei einem wiederholten Lauf ist sie schon freigegeben. Das ist kein
// Grund abzubrechen – der entscheidende Fall kommt erst danach.
console.log(' (war bereits freigegeben)');
}
console.log('\n3) Nach der Freigabe, vor der Abgabe – der entscheidende Fall');
await abmelden();
await anmelden(AZUBI);
await seite.goto(`${BASIS}/kontrollen/woche-01`);
await seite.waitForSelector('.frage-text');
console.log(` Fragen sichtbar: ${(await seite.$$('.frage-text')).length}`);
const leck = await lecksuche('freigegeben, nicht abgegeben');
console.log('\n4) Nach dem Zwischenspeichern einer Antwort');
await seite.locator('textarea[id^=antwort-]').first().fill('Person, Gerät, Fehlerbild, Zeitpunkt.');
await seite.waitForTimeout(1600);
await seite.reload();
await seite.waitForSelector('.frage-text');
const leck2 = await lecksuche('nach Zwischenspeichern');
console.log('\n5) Nach der Abgabe muss sie da sein');
await seite.click('button:has-text("Kontrolle abgeben")');
await seite.waitForTimeout(2500);
await seite.reload();
await seite.waitForSelector('h1');
const nachher = await lecksuche('abgegeben');
console.log(` Musterlösungen jetzt sichtbar: ${nachher.length} von ${bruchstuecke.length}`);
await browser.close();
const gescheitert = leck.length + leck2.length;
console.log(`\n${gescheitert === 0 ? 'BESTANDEN' : 'DURCHGEFALLEN'}: vor der Abgabe ${gescheitert} Leck(s).`);
process.exit(gescheitert === 0 && nachher.length === bruchstuecke.length ? 0 : 1);
+94 -2
View File
@@ -74,11 +74,46 @@ async function main() {
await seite.waitForSelector('#neu-name');
await bild(seite, '03-verwaltung');
await seite.goto(`${BASIS}/wochenplan`);
await seite.waitForSelector('h1');
await bild(seite, '04-wochenplan');
await seite.goto(`${BASIS}/aufgaben`);
await seite.waitForSelector('h1');
const abnahme = await seite.$('a[href*="/aufgaben/1.1?azubi="]');
if (abnahme) {
await abnahme.click();
await seite.waitForSelector('#abnahme');
await bild(seite, '05-aufgabe-abnahme');
}
// --- Auswertung und Feedbackgespräch (Ausbildersicht) ---
await seite.goto(`${BASIS}/auswertung`);
await seite.waitForSelector('h1');
await bild(seite, '06-auswertung');
const luecke = await seite.$('[data-stufe="ohne_inhalt"]');
if (luecke) {
await luecke.scrollIntoViewIfNeeded();
await bild(seite, '07-rahmenplanabdeckung', false);
}
await seite.goto(`${BASIS}/feedback`);
await seite.waitForSelector('h1');
await bild(seite, '08-feedback-liste');
const gespraech = await seite.$('.gespraechszeile');
if (gespraech) {
await gespraech.click();
await seite.waitForSelector('#bogen');
await bild(seite, '09-feedbackgespraech');
}
await abmelden(seite);
// --- Azubi ---
await anmelden(seite, AZUBI);
await bild(seite, '10-azubi-start');
await bild(seite, '10-azubi-woche');
await seite.goto(`${BASIS}/handouts`);
await seite.waitForSelector('h1');
@@ -93,9 +128,57 @@ async function main() {
await seite.click('.pruefungsfrage > summary');
await bild(seite, '13-handout-n3');
await seite.goto(`${BASIS}/karten`);
await seite.waitForSelector('h1');
await bild(seite, '14-merkkarten');
await seite.goto(`${BASIS}/aufgaben`);
await seite.waitForSelector('h1');
await bild(seite, '16-aufgaben-liste');
await seite.goto(`${BASIS}/aufgaben/1.1`);
await seite.waitForSelector('h1');
await bild(seite, '17-aufgabe-azubi');
await seite.goto(`${BASIS}/checklisten`);
await seite.waitForSelector('h1');
await bild(seite, '18-checklisten');
await seite.goto(`${BASIS}/berichtsheft`);
await seite.waitForSelector('h1');
await bild(seite, '21-berichtsheft');
await seite.goto(`${BASIS}/kontrollen`);
await seite.waitForSelector('h1');
await bild(seite, '23-kontrollen');
await seite.goto(`${BASIS}/kontrollen/woche-03`);
await seite.waitForSelector('h1');
await bild(seite, '24-kontrolle-ergebnis');
// Einzelblatt und Checklisten-Durchlauf jeweils von ihrer eigenen Liste aus –
// vorher wurde hier auf der Kontrollseite gesucht, wo es sie gar nicht gibt.
await seite.goto(`${BASIS}/berichtsheft`);
await seite.waitForSelector('h1');
const blatt = await seite.$('.blattzeile-link');
if (blatt) {
await blatt.click();
await seite.waitForSelector('h1');
await bild(seite, '22-wochenblatt');
}
await seite.goto(`${BASIS}/checklisten`);
await seite.waitForSelector('h1');
const lauf = await seite.$('.laufzeile-haupt');
if (lauf) {
await lauf.click();
await seite.waitForSelector('.schrittliste');
await bild(seite, '19-checklisten-lauf');
}
await seite.goto(`${BASIS}/glossar`);
await seite.waitForSelector('h1');
await bild(seite, '14-glossar');
await bild(seite, '15-glossar');
// --- Dunkle Darstellung ---
await seite.goto(`${BASIS}/handouts/N3`);
@@ -119,6 +202,15 @@ async function main() {
await handySeite.waitForSelector('h1');
await bild(handySeite, '30-handy-handout');
await handySeite.goto(`${BASIS}/karten/lernen`);
await handySeite.waitForSelector('.lernkarte, h1');
if (await handySeite.$('.lernkarte')) {
await bild(handySeite, '31-handy-lernmodus-frage', false);
await handySeite.tap('.lernkarte');
await handySeite.waitForSelector('.lernkarte-antwort');
await bild(handySeite, '32-handy-lernmodus-antwort', false);
}
await browser.close();
console.log('Fertig.');
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Erzeugt die Symbole für die installierte Anwendung aus dem Zeichen selbst –
* ohne Bilddienst, ohne Netz.
*
* npm run symbole
*
* Zwei Fassungen, weil Android sie unterschiedlich behandelt:
*
* symbol-192.png / symbol-512.png das Zeichen, wie es ist
* symbol-maskabel-512.png derselbe Inhalt, aber randvoll auf
* Petrol und nach innen gerückt. Android
* schneidet das Symbol in eine eigene
* Form – ein Kreis, ein Tropfen, was das
* Gerät gerade mag. Was zu weit außen
* liegt, ist dann weg.
*/
import fs from 'node:fs';
import path from 'node:path';
import { chromium } from '@playwright/test';
const ZIEL = path.join(process.cwd(), 'public');
/** Das Zeichen. Gleicher Inhalt wie `public/lernbar.svg`. */
function zeichen(randAnteil: number, eckenRadius: number): string {
// Die drei Balken liegen im Original in einem Feld von 64 × 64 Punkten.
// `randAnteil` rückt sie nach innen, ohne den Grund zu verkleinern.
const innen = 64 * (1 - 2 * randAnteil);
const versatz = 64 * randAnteil;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="${eckenRadius}" fill="#1F4E5F"/>
<g transform="translate(${versatz} ${versatz}) scale(${innen / 64})">
<rect x="14" y="16" width="10" height="32" rx="2" fill="#EDF3F5"/>
<rect x="27" y="16" width="10" height="32" rx="2" fill="#EDF3F5"/>
<rect x="40" y="16" width="10" height="32" rx="2" fill="#D98A2B"/>
</g>
</svg>`;
}
type Auftrag = { datei: string; groesse: number; svg: string };
const auftraege: Auftrag[] = [
{ datei: 'symbol-192.png', groesse: 192, svg: zeichen(0, 12) },
{ datei: 'symbol-512.png', groesse: 512, svg: zeichen(0, 12) },
// Randvoll und nach innen gerückt: der Beschnitt darf nichts abschneiden.
{ datei: 'symbol-maskabel-512.png', groesse: 512, svg: zeichen(0.14, 0) },
];
async function main() {
const browser = await chromium.launch();
for (const auftrag of auftraege) {
const seite = await browser.newPage({
viewport: { width: auftrag.groesse, height: auftrag.groesse },
deviceScaleFactor: 1,
});
await seite.setContent(
`<!doctype html><style>html,body{margin:0;padding:0}svg{display:block;width:100vw;height:100vh}</style>${auftrag.svg}`,
);
const datei = path.join(ZIEL, auftrag.datei);
await seite.screenshot({ path: datei, omitBackground: false });
await seite.close();
console.log(` public/${auftrag.datei} (${auftrag.groesse}×${auftrag.groesse}, ${fs.statSync(datei).size} Bytes)`);
}
await browser.close();
console.log('Symbole erzeugt.');
}
main().catch((fehler) => {
console.error('Die Symbole konnten nicht erzeugt werden.');
console.error(fehler instanceof Error ? fehler.message : fehler);
process.exit(1);
});
+230
View File
@@ -0,0 +1,230 @@
import Link from 'next/link';
import { Fortschrittsbalken } from '@/komponenten/Fortschrittsbalken';
import type { Wochensicht } from '@/lib/planung/sicht';
import { alsDatumText, kalenderwocheText } from '@/lib/zeit';
const statusText: Record<string, { text: string; ton: string; zeichen: string }> = {
geplant: { text: 'geplant', ton: 'neutral', zeichen: '○' },
laufend: { text: 'läuft', ton: 'warnung', zeichen: '▶' },
abgeschlossen: { text: 'abgeschlossen', ton: 'gut', zeichen: '✓' },
};
/**
* Die Wochenansicht – für den Azubi die Startseite, für den Ausbilder dieselbe
* Woche in der Ansicht seines Azubis.
*/
export function WochenAnsicht({
sicht,
istAzubi,
wochenLink,
}: {
sicht: Wochensicht;
istAzubi: boolean;
/** Vorlage für den Verweis auf eine andere Woche, `{nr}` wird ersetzt. */
wochenLink: string;
}) {
const zurWoche = (nummer: number) => wochenLink.replace('{nr}', String(nummer));
const { woche, planung, fortschritt, tage } = sicht;
const status = statusText[planung.status] ?? statusText.geplant!;
return (
<div className="stapel">
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="wochen-nummer">Woche {woche.nummer}</span>
<span className="marke-status" data-ton={status.ton}>
{status.zeichen} {status.text}
</span>
<span className="leise">
{alsDatumText(planung.geplantVon)} – {alsDatumText(planung.geplantBis)} · {kalenderwocheText(planung.geplantVon)}
</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{woche.titel}</h1>
<p className="leitfrage">{woche.leitfrage}</p>
<p className="leise" style={{ marginBottom: 0 }}>{woche.ziel}</p>
</header>
{planung.tatsaechlichVon && planung.tatsaechlichVon !== planung.geplantVon ? (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
Tatsächlich begonnen am {alsDatumText(planung.tatsaechlichVon)}, geplant war der{' '}
{alsDatumText(planung.geplantVon)}.
</p>
) : null}
{planung.notiz ? (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
<strong>Notiz des Ausbilders:</strong> {planung.notiz}
</p>
) : null}
<section className="karte stapel">
<Fortschrittsbalken fortschritt={fortschritt} />
{fortschritt.offen.length > 0 ? (
<div>
<h2 style={{ fontSize: '1rem', marginBottom: '0.4rem' }}>
{istAzubi ? 'Das ist diese Woche noch offen' : 'Offen bei deinem Azubi'}
</h2>
<ul className="offen-liste">
{fortschritt.offen.map((bereich) => (
<li key={bereich.schluessel}>
<span>{bereich.titel}</span>
<span className="leise">
{bereich.zaehlwerk.erledigt} von {bereich.zaehlwerk.gesamt}
</span>
</li>
))}
</ul>
</div>
) : fortschritt.gesamt > 0 ? (
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
✓ Diese Woche ist vollständig abgearbeitet.
</p>
) : null}
</section>
<section aria-labelledby="tage" className="stapel">
<h2 id="tage">Die Woche</h2>
<ol className="wochentage">
{tage.map((tag) => (
<li key={tag.tag} className={`wochentag${tag.istHeute ? ' wochentag-heute' : ''}`}>
<div className="wochentag-kopf">
<strong>{tag.tag}</strong>
<span className="leise">{alsDatumText(tag.datum)}</span>
{tag.istHeute ? <span className="marke-status" data-ton="warnung">● heute</span> : null}
</div>
<div className="wochentag-inhalt">
<div>
<span className="wochentag-marke">Theorie</span>
{tag.theorie.length === 0 ? (
<span className="leise">–</span>
) : (
<ul className="theorie-liste">
{tag.theorie.map((handout) => {
const gelesen = sicht.handouts.find((h) => h.handout.id === handout.id)?.gelesen ?? false;
return (
<li key={handout.id}>
<Link href={`/handouts/${handout.id}`}>
<span className="handout-kuerzel">{handout.id}</span> {handout.titel}
</Link>
{gelesen ? (
<span className="marke-status" data-ton="gut">
✓ gelesen
</span>
) : null}
</li>
);
})}
</ul>
)}
</div>
<div>
<span className="wochentag-marke">Praxis</span>
<span>{tag.praxis || '–'}</span>
</div>
</div>
</li>
))}
</ol>
</section>
<div className="raster">
<section className="karte" aria-labelledby="woche-handouts">
<h2 id="woche-handouts" style={{ fontSize: '1rem' }}>
Handouts dieser Woche
</h2>
{sicht.handouts.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{sicht.handouts.map(({ handout, gelesen }) => (
<li key={handout.id} className="reihe" style={{ gap: '0.5rem', flexWrap: 'nowrap' }}>
<span className="handout-kuerzel">{handout.id}</span>
<Link href={`/handouts/${handout.id}`} style={{ flex: 1 }}>
{handout.titel}
</Link>
<span aria-label={gelesen ? 'gelesen' : 'noch nicht gelesen'} title={gelesen ? 'gelesen' : 'offen'}>
{gelesen ? '✓' : '○'}
</span>
</li>
))}
</ul>
)}
</section>
<section className="karte" aria-labelledby="woche-aufgaben">
<h2 id="woche-aufgaben" style={{ fontSize: '1rem' }}>
Praxisaufgaben
</h2>
{sicht.aufgaben.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{sicht.aufgaben.map((aufgabe) => (
<li key={aufgabe.id}>
<strong>{aufgabe.id}</strong> {aufgabe.titel}
<br />
<span className="leise">{aufgabe.zeitrahmen}</span>
</li>
))}
</ul>
)}
</section>
<section className="karte" aria-labelledby="woche-checklisten">
<h2 id="woche-checklisten" style={{ fontSize: '1rem' }}>
Checklisten
</h2>
{sicht.checklisten.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{sicht.checklisten.map((liste) => (
<li key={liste.id}>{liste.titel}</li>
))}
</ul>
)}
</section>
<section className="karte" aria-labelledby="woche-kontrolle">
<h2 id="woche-kontrolle" style={{ fontSize: '1rem' }}>
Lernzielkontrolle
</h2>
{sicht.kontrolle ? (
<p style={{ marginBottom: 0 }}>
{sicht.kontrolle.titel}
<br />
<span className="leise">
{sicht.kontrolle.fragen.length} Fragen ·{' '}
{sicht.kontrolle.fragen.reduce((summe, f) => summe + f.punkte, 0)} Punkte
{sicht.kontrolle.dauer_minuten ? ` · ${sicht.kontrolle.dauer_minuten} Minuten` : ''}
</span>
</p>
) : (
<p className="leise" style={{ marginBottom: 0 }}>Diese Woche keine.</p>
)}
</section>
</div>
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Andere Wochen">
{sicht.vorherige ? (
<Link href={zurWoche(sicht.vorherige)} className="knopf knopf-still">
← Woche {sicht.vorherige}
</Link>
) : (
<span />
)}
{sicht.naechste ? (
<Link href={zurWoche(sicht.naechste)} className="knopf knopf-still">
Woche {sicht.naechste} →
</Link>
) : (
<span />
)}
</nav>
</div>
);
}
@@ -0,0 +1,21 @@
import type { AufgabenStatus } from '@/lib/db/schema';
/**
* Status einer Praxisaufgabe. Nie nur über die Farbe – jede Stufe trägt ein
* eigenes Zeichen und ihren Namen.
*/
export const statusText: Record<AufgabenStatus, { text: string; ton: string; zeichen: string }> = {
offen: { text: 'offen', ton: 'neutral', zeichen: '○' },
in_arbeit: { text: 'in Arbeit', ton: 'warnung', zeichen: '◐' },
zur_abnahme: { text: 'zur Abnahme', ton: 'warnung', zeichen: '▲' },
abgenommen: { text: 'abgenommen', ton: 'gut', zeichen: '✓' },
};
export function StatusMarke({ status }: { status: AufgabenStatus }) {
const marke = statusText[status];
return (
<span className="marke-status" data-ton={marke.ton}>
{marke.zeichen} {marke.text}
</span>
);
}
@@ -0,0 +1,225 @@
'use client';
import { useActionState, useRef, useState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import {
anhangHochladen,
bewerten,
eigeneNotizSpeichern,
eigenenStatusSetzen,
type Rueckmeldung,
} from '../aktionen';
import type { AufgabenStatus } from '@/lib/db/schema';
export function StatusWaehlen({ aufgabeId, status }: { aufgabeId: string; status: AufgabenStatus }) {
const stufen: { wert: AufgabenStatus; text: string }[] = [
{ wert: 'offen', text: 'Offen' },
{ wert: 'in_arbeit', text: 'In Arbeit' },
{ wert: 'zur_abnahme', text: 'Zur Abnahme' },
];
return (
<form action={eigenenStatusSetzen} className="statuswahl">
<input type="hidden" name="aufgabeId" value={aufgabeId} />
{stufen.map((stufe) => (
<button
key={stufe.wert}
type="submit"
name="status"
value={stufe.wert}
className={`knopf ${status === stufe.wert ? '' : 'knopf-zweit'}`}
disabled={status === stufe.wert || status === 'abgenommen'}
aria-pressed={status === stufe.wert}
>
{stufe.text}
</button>
))}
</form>
);
}
export function NotizFormular({
aufgabeId,
notiz,
gesperrt,
}: {
aufgabeId: string;
notiz: string;
gesperrt: boolean;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(eigeneNotizSpeichern, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="aufgabeId" value={aufgabeId} />
<label htmlFor="aufgaben-notiz">Deine Notizen</label>
<textarea
id="aufgaben-notiz"
name="notiz"
defaultValue={notiz}
rows={8}
maxLength={8000}
disabled={gesperrt}
placeholder="Was hast du gemacht, was ist aufgefallen, wo hakte es? Diese Notizen helfen dir später beim Berichtsheft."
/>
{gesperrt ? (
<p className="leise" style={{ margin: 0 }}>
Die Aufgabe ist abgenommen und damit gesperrt.
</p>
) : (
<div>
<Absenden className="knopf knopf-zweit">Notizen speichern</Absenden>
</div>
)}
</form>
);
}
/** Muss zur Prüfung auf dem Server passen – siehe lib/anhaenge/dateien.ts. */
const HOECHSTGROESSE = 25 * 1024 * 1024;
export function AnhangFormular({ aufgabeId, gesperrt }: { aufgabeId: string; gesperrt: boolean }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(anhangHochladen, {});
const [namen, setzeNamen] = useState<string[]>([]);
const [zuGross, setzeZuGross] = useState<string[]>([]);
const feld = useRef<HTMLInputElement>(null);
if (gesperrt) return null;
function beiAuswahl(dateien: FileList | null) {
const liste = Array.from(dateien ?? []);
setzeNamen(liste.map((d) => d.name));
// Vorab im Browser prüfen: Eine zu große Datei erst hochzuladen und dann
// abzulehnen kostet den Azubi im Zweifel Minuten am Mobilfunk.
setzeZuGross(liste.filter((d) => d.size > HOECHSTGROESSE).map((d) => d.name));
}
return (
<form
action={(daten) => {
setzeNamen([]);
setzeZuGross([]);
if (feld.current) feld.current.value = '';
return aktion(daten);
}}
className="stapel-eng"
>
<Meldung zustand={zustand} />
<input type="hidden" name="aufgabeId" value={aufgabeId} />
<label htmlFor="anhang-datei">Dateien anhängen</label>
<input
id="anhang-datei"
ref={feld}
name="datei"
type="file"
multiple
accept="image/jpeg,image/png,image/webp,image/gif,image/heic,application/pdf,text/plain,text/csv"
onChange={(e) => beiAuswahl(e.target.files)}
/>
<p className="leise" style={{ margin: 0 }}>
Fotos vom Aufbau, Bildschirmfotos, ein Netzplan als PDF. Höchstens 25 MB je Datei.
</p>
{zuGross.length > 0 ? (
<p className="hinweis hinweis-fehler" role="alert" style={{ margin: 0 }}>
Zu groß (mehr als 25 MB): {zuGross.join(', ')}. Bitte die Datei verkleinern.
</p>
) : namen.length > 0 ? (
<p className="leise" style={{ margin: 0 }}>
Ausgewählt: {namen.join(', ')}
</p>
) : null}
<div>
<Absenden className="knopf knopf-zweit" wartetext="Wird übertragen …">
Hochladen
</Absenden>
</div>
</form>
);
}
export function BewertungsFormular({
aufgabeId,
azubiId,
kriterien,
erfuellt,
bewertung,
rueckmeldung,
}: {
aufgabeId: string;
azubiId: string;
kriterien: string[];
erfuellt: number[];
bewertung: string;
rueckmeldung: string;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(bewerten, {});
const [angehakt, setzeAngehakt] = useState<number[]>(erfuellt);
function umschalten(stelle: number) {
setzeAngehakt((vorher) =>
vorher.includes(stelle) ? vorher.filter((n) => n !== stelle) : [...vorher, stelle],
);
}
return (
<form action={aktion} className="stapel">
<Meldung zustand={zustand} />
<input type="hidden" name="aufgabeId" value={aufgabeId} />
<input type="hidden" name="azubiId" value={azubiId} />
<fieldset className="kriterien">
<legend>
Bewertungskriterien{' '}
<span className="leise">
({angehakt.length} von {kriterien.length} erfüllt)
</span>
</legend>
{kriterien.map((text, stelle) => (
<label key={stelle} className="ankreuzfeld kriterium">
<input
type="checkbox"
name="kriterium"
value={stelle}
checked={angehakt.includes(stelle)}
onChange={() => umschalten(stelle)}
/>
<span style={{ fontWeight: 400 }}>{text}</span>
</label>
))}
</fieldset>
<div className="feld">
<label htmlFor="bewertung">Note oder kurze Bewertung</label>
<input
id="bewertung"
name="bewertung"
type="text"
maxLength={60}
defaultValue={bewertung}
placeholder={'z. B. "gut" oder die Note 2'}
/>
</div>
<div className="feld">
<label htmlFor="rueckmeldung">Rückmeldung an den Azubi</label>
<textarea
id="rueckmeldung"
name="rueckmeldung"
rows={5}
maxLength={4000}
defaultValue={rueckmeldung}
placeholder="Was war gut, was fehlt noch? Konkret genug, dass er damit weiterarbeiten kann."
/>
</div>
<div>
<Absenden className="knopf">Bewertung speichern</Absenden>
</div>
</form>
);
}
+310
View File
@@ -0,0 +1,310 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { erfordereBenutzer, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { inhalte } from '@/lib/inhalte';
import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise';
import { anhaengeVon } from '@/lib/repos/anhaenge';
import { erfuellteKriterien, standVon } from '@/lib/repos/aufgaben';
import { istBild, megabyte } from '@/lib/anhaenge/dateien';
import { alsZeitpunktText } from '@/lib/zeit';
import { StatusMarke } from '../StatusMarke';
import { AnhangFormular, BewertungsFormular, NotizFormular, StatusWaehlen } from './Formulare';
import { anhangEntfernen, aufgabeAbnehmen, aufgabeZurueckgeben } from '../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const aufgabe = inhalte().aufgabeNach.get(decodeURIComponent(id));
return { title: aufgabe ? `${aufgabe.id} – ${aufgabe.titel}` : 'Praxisaufgabe' };
}
export default async function AufgabenSeite({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ azubi?: string }>;
}) {
const benutzer = await erfordereBenutzer();
const { id } = await params;
const { azubi: azubiParameter } = await searchParams;
const alle = inhalte();
const aufgabe = alle.aufgabeNach.get(decodeURIComponent(id));
if (!aufgabe) notFound();
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (azubiParameter ?? '');
if (!azubiId) notFound();
// Serverseitige Rechteprüfung – die Kennung aus der Adresszeile wird nie geglaubt.
erfordereSichtAufAzubi(benutzer, azubiId);
const istAzubi = benutzer.rolle === 'azubi';
const azubi = istAzubi ? benutzer : benutzerNachId(azubiId);
if (!azubi) notFound();
const stand = standVon(azubiId, aufgabe.id);
const status = stand?.status ?? 'offen';
const gesperrt = status === 'abgenommen';
const erfuellt = erfuellteKriterien(stand);
const anhaenge = anhaengeVon('aufgabe', `${aufgabe.id}::${azubiId}`);
const lernfeld = alle.lernfeldNach.get(aufgabe.lernfeld);
const positionen = aufgabe.rahmenplan.map((p) => alle.positionNach.get(p)).filter((p) => p !== undefined);
const abnehmer = stand?.abgenommenVon ? benutzerNachId(stand.abgenommenVon) : null;
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/aufgaben">Praxisaufgaben</Link>
{!istAzubi ? ` › ${azubi.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="aufgaben-nummer aufgaben-nummer-gross">{aufgabe.id}</span>
<StatusMarke status={status} />
<span className="leise">
Woche {aufgabe.woche} · {aufgabe.zeitrahmen}
</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{aufgabe.titel}</h1>
<p className="handout-worum">{aufgabe.ziel}</p>
</header>
<div className="handout-zuordnung karte">
<dl>
<div>
<dt>Ausbildungsrahmenplan</dt>
<dd>
{positionen.map((p) => (
<span key={p.id} className="zuordnung-marke" title={`${p.titel} (${p.verordnung})`}>
<strong>{p.id}</strong> {p.kurz}
</span>
))}
</dd>
</div>
<div>
<dt>Lernfeld</dt>
<dd>
{lernfeld ? (
<span className="zuordnung-marke">
<strong>{lernfeld.id}</strong> {lernfeld.titel}
</span>
) : (
'–'
)}
</dd>
</div>
</dl>
</div>
{aufgabe.material.length > 0 ? (
<section className="karte" aria-labelledby="material">
<h2 id="material" style={{ fontSize: '1rem' }}>
Benötigtes Material
</h2>
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
{aufgabe.material.map((posten, stelle) => (
<li key={stelle}>{posten}</li>
))}
</ul>
</section>
) : null}
{aufgabe.abschnitte.map((abschnitt, stelle) =>
abschnitt.titel ? (
<section key={stelle} aria-label={abschnitt.titel}>
<h2>{abschnitt.titel}</h2>
<div
className="fliesstext"
dangerouslySetInnerHTML={{ __html: verlinkeGlossar(abschnitt.html, alle.glossar) }}
/>
</section>
) : null,
)}
<section className="karte stapel" aria-labelledby="kriterien-liste">
<h2 id="kriterien-liste" style={{ marginBottom: 0 }}>
Bewertungskriterien
</h2>
<p className="leise" style={{ margin: 0 }}>
{erfuellt.length} von {aufgabe.kriterien.length} erfüllt · abgehakt wird vom Ausbilder
</p>
<ul className="kriterienliste">
{aufgabe.kriterien.map((text, stelle) => (
<li key={stelle} data-erfuellt={erfuellt.includes(stelle) ? 'ja' : 'nein'}>
<span aria-hidden="true">{erfuellt.includes(stelle) ? '✓' : '○'}</span>
<span>{text}</span>
<span className="nur-lesehilfe">{erfuellt.includes(stelle) ? ' – erfüllt' : ' – noch offen'}</span>
</li>
))}
</ul>
</section>
{/* --- Bearbeitung durch den Azubi --- */}
{istAzubi ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="bearbeitung">
<h2 id="bearbeitung" style={{ marginBottom: 0 }}>
Deine Bearbeitung
</h2>
<div>
<p className="leise" style={{ margin: '0 0 0.35rem' }}>
Stand setzen
</p>
<StatusWaehlen aufgabeId={aufgabe.id} status={status} />
{status === 'zur_abnahme' ? (
<p className="hinweis hinweis-warnung" style={{ marginTop: '0.6rem', marginBottom: 0 }}>
Abgegeben am {alsZeitpunktText(stand?.abgegebenAm)} – wartet auf die Abnahme.
</p>
) : null}
{gesperrt ? (
<p className="hinweis hinweis-gut" style={{ marginTop: '0.6rem', marginBottom: 0 }}>
✓ Abgenommen am {alsZeitpunktText(stand?.abgenommenAm)}
{abnehmer ? ` von ${abnehmer.name}` : ''}.
</p>
) : null}
</div>
<NotizFormular aufgabeId={aufgabe.id} notiz={stand?.notiz ?? ''} gesperrt={gesperrt} />
<AnhangFormular aufgabeId={aufgabe.id} gesperrt={gesperrt} />
</section>
) : (
<section className="karte stapel" aria-labelledby="azubi-stand">
<h2 id="azubi-stand" style={{ marginBottom: 0 }}>
Bearbeitung von {azubi.name}
</h2>
<p className="leise" style={{ margin: 0 }}>
{stand?.begonnenAm ? `Begonnen ${alsZeitpunktText(stand.begonnenAm)}` : 'Noch nicht begonnen'}
{stand?.abgegebenAm ? ` · abgegeben ${alsZeitpunktText(stand.abgegebenAm)}` : ''}
{stand?.abgenommenAm ? ` · abgenommen ${alsZeitpunktText(stand.abgenommenAm)}` : ''}
</p>
<div>
<h3 style={{ fontSize: '0.95rem', marginBottom: '0.3rem' }}>Notizen</h3>
{stand?.notiz ? (
<p className="notiz-anzeige">{stand.notiz}</p>
) : (
<p className="leise" style={{ margin: 0 }}>
Noch keine Notizen.
</p>
)}
</div>
</section>
)}
{/* --- Anhänge --- */}
<section className="karte stapel" aria-labelledby="anhaenge">
<h2 id="anhaenge" style={{ marginBottom: 0 }}>
Anhänge{anhaenge.length > 0 ? ` (${anhaenge.length})` : ''}
</h2>
{anhaenge.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Noch nichts angehängt.
</p>
) : (
<ul className="anhangliste">
{anhaenge.map((eintrag) => (
<li key={eintrag.id}>
<a href={`/anhaenge/${eintrag.id}`} target="_blank" rel="noreferrer" className="anhang-kachel">
{istBild(eintrag.mimeTyp) ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={`/anhaenge/${eintrag.id}`} alt={eintrag.beschriftung ?? eintrag.dateiname} />
) : (
<span className="anhang-symbol" aria-hidden="true">
{eintrag.mimeTyp === 'application/pdf' ? 'PDF' : 'TXT'}
</span>
)}
<span className="anhang-name">{eintrag.beschriftung ?? eintrag.dateiname}</span>
<span className="leise">{megabyte(eintrag.groesse)}</span>
</a>
{istAzubi && !gesperrt ? (
<form action={anhangEntfernen} className="nicht-drucken">
<input type="hidden" name="anhangId" value={eintrag.id} />
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
<button type="submit" className="knopf knopf-still">
Entfernen
</button>
</form>
) : null}
</li>
))}
</ul>
)}
</section>
{/* --- Bewertung und Abnahme --- */}
{!istAzubi ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="abnahme">
<h2 id="abnahme" style={{ marginBottom: 0 }}>
Bewerten und abnehmen
</h2>
<BewertungsFormular
aufgabeId={aufgabe.id}
azubiId={azubiId}
kriterien={aufgabe.kriterien}
erfuellt={erfuellt}
bewertung={stand?.bewertung ?? ''}
rueckmeldung={stand?.rueckmeldung ?? ''}
/>
<hr />
<div className="reihe">
<form action={aufgabeAbnehmen}>
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
<input type="hidden" name="azubiId" value={azubiId} />
<button type="submit" className="knopf knopf-akzent" disabled={status === 'abgenommen'}>
{status === 'abgenommen' ? 'Bereits abgenommen' : 'Aufgabe abnehmen'}
</button>
</form>
<form action={aufgabeZurueckgeben}>
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
<input type="hidden" name="azubiId" value={azubiId} />
<button type="submit" className="knopf knopf-zweit" disabled={status === 'offen'}>
Zur Nacharbeit zurückgeben
</button>
</form>
</div>
<p className="leise" style={{ marginBottom: 0 }}>
Abnehmen sperrt die Aufgabe für den Azubi. Zurückgeben setzt sie auf &bdquo;in Arbeit&ldquo; – schreib in dem
Fall vorher eine Rückmeldung, sonst weiß er nicht, was fehlt.
</p>
</section>
) : null}
{/* --- Ergebnis, für beide sichtbar --- */}
{stand?.bewertung || stand?.rueckmeldung ? (
<section className="karte stapel-eng" aria-labelledby="ergebnis">
<h2 id="ergebnis" style={{ marginBottom: 0 }}>
Rückmeldung des Ausbilders
</h2>
{stand.bewertung ? (
<p style={{ margin: 0 }}>
<strong>Bewertung: {stand.bewertung}</strong>
</p>
) : null}
{stand.rueckmeldung ? <p className="notiz-anzeige">{stand.rueckmeldung}</p> : null}
</section>
) : null}
<p className="leise nicht-drucken" style={{ marginBottom: 0 }}>
Quelle: <code>{aufgabe.datei}</code>
</p>
</article>
);
}
+186
View File
@@ -0,0 +1,186 @@
'use server';
import { revalidatePath } from 'next/cache';
import { erfordereBenutzer, erfordereBewertungsrecht, erfordereZugriffAufAzubi } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { anhangAblegen, anhangLoeschen, anhangNachId } from '@/lib/repos/anhaenge';
import {
abnehmen,
bewertungSetzen,
notizSetzen,
statusSetzen,
standVon,
zurueckgeben,
} from '@/lib/repos/aufgaben';
import { aufgabenStatus, type AufgabenStatus } from '@/lib/db/schema';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
function aktualisiere(aufgabeId: string) {
revalidatePath('/aufgaben');
revalidatePath(`/aufgaben/${aufgabeId}`);
revalidatePath('/');
}
function aufgabeGibtEs(aufgabeId: string): boolean {
return inhalte().aufgabeNach.has(aufgabeId);
}
// --- Der Azubi an seiner eigenen Aufgabe -----------------------------------
export async function eigenenStatusSetzen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return;
const aufgabeId = String(formular.get('aufgabeId') ?? '');
const status = String(formular.get('status') ?? '') as AufgabenStatus;
if (!aufgabeGibtEs(aufgabeId)) return;
if (!aufgabenStatus.includes(status)) return;
// Abnehmen darf nur der Ausbilder.
if (status === 'abgenommen') return;
statusSetzen(benutzer.id, aufgabeId, status);
aktualisiere(aufgabeId);
}
export async function eigeneNotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return { fehler: 'Nur der Azubi schreibt seine eigenen Notizen.' };
const aufgabeId = String(formular.get('aufgabeId') ?? '');
const notiz = String(formular.get('notiz') ?? '');
if (!aufgabeGibtEs(aufgabeId)) return { fehler: 'Diese Aufgabe gibt es nicht.' };
if (notiz.length > 8000) return { fehler: 'Die Notiz ist zu lang – höchstens 8000 Zeichen.' };
notizSetzen(benutzer.id, aufgabeId, notiz);
aktualisiere(aufgabeId);
return { erfolg: 'Notiz gespeichert.' };
}
export async function anhangHochladen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return { fehler: 'Anhänge lädt der Azubi hoch.' };
const aufgabeId = String(formular.get('aufgabeId') ?? '');
if (!aufgabeGibtEs(aufgabeId)) return { fehler: 'Diese Aufgabe gibt es nicht.' };
const stand = standVon(benutzer.id, aufgabeId);
if (stand?.status === 'abgenommen') {
return { fehler: 'Die Aufgabe ist abgenommen – sie lässt sich nicht mehr ändern.' };
}
const dateien = formular.getAll('datei').filter((d): d is File => d instanceof File && d.size > 0);
if (dateien.length === 0) return { fehler: 'Keine Datei ausgewählt.' };
const beschriftung = String(formular.get('beschriftung') ?? '');
const fehler: string[] = [];
let angelegt = 0;
for (const datei of dateien) {
const ergebnis = await anhangAblegen({
bezugTyp: 'aufgabe',
// Der Bezug ist Aufgabe **und** Azubi – sonst sähe ein zweiter Azubi
// die Anhänge des ersten an derselben Aufgabe.
bezugId: `${aufgabeId}::${benutzer.id}`,
benutzerId: benutzer.id,
datei,
beschriftung: dateien.length === 1 ? beschriftung : undefined,
});
if ('fehler' in ergebnis) {
fehler.push(`${datei.name}: ${ergebnis.fehler}`);
} else {
angelegt += 1;
}
}
aktualisiere(aufgabeId);
if (angelegt === 0) return { fehler: fehler.join(' · ') };
if (fehler.length > 0) {
return { fehler: `${angelegt} übernommen, aber: ${fehler.join(' · ')}` };
}
return { erfolg: `${angelegt} ${angelegt === 1 ? 'Anhang' : 'Anhänge'} hinzugefügt.` };
}
export async function anhangEntfernen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
const anhangId = String(formular.get('anhangId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
const eintrag = anhangNachId(anhangId);
if (!eintrag) return;
// Nur der eigene Anhang, und nur solange die Aufgabe nicht abgenommen ist.
if (eintrag.benutzerId !== benutzer.id) return;
const stand = standVon(benutzer.id, aufgabeId);
if (stand?.status === 'abgenommen') return;
await anhangLoeschen(anhangId);
aktualisiere(aufgabeId);
}
// --- Der Ausbilder an der Aufgabe seines Azubis ----------------------------
export async function bewerten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
const aufgabe = inhalte().aufgabeNach.get(aufgabeId);
if (!aufgabe) return { fehler: 'Diese Aufgabe gibt es nicht.' };
const kriterien = formular
.getAll('kriterium')
.map((wert) => Number(wert))
.filter((n) => Number.isInteger(n) && n >= 0 && n < aufgabe.kriterien.length);
const bewertung = String(formular.get('bewertung') ?? '').trim();
const rueckmeldung = String(formular.get('rueckmeldung') ?? '').trim();
if (bewertung.length > 60) return { fehler: 'Die Bewertung ist zu lang – höchstens 60 Zeichen.' };
if (rueckmeldung.length > 4000) return { fehler: 'Die Rückmeldung ist zu lang – höchstens 4000 Zeichen.' };
bewertungSetzen(azubiId, aufgabeId, {
kriterien,
bewertung: bewertung || null,
rueckmeldung: rueckmeldung || null,
});
aktualisiere(aufgabeId);
return { erfolg: `Bewertung gespeichert – ${kriterien.length} von ${aufgabe.kriterien.length} Kriterien erfüllt.` };
}
export async function aufgabeAbnehmen(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
if (!aufgabeGibtEs(aufgabeId)) return;
abnehmen(azubiId, aufgabeId, ausbilder.id);
aktualisiere(aufgabeId);
}
export async function aufgabeZurueckgeben(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
if (!aufgabeGibtEs(aufgabeId)) return;
zurueckgeben(azubiId, aufgabeId);
aktualisiere(aufgabeId);
}
/** Wird von der Aufgabenseite gebraucht, um fremde Daten überhaupt lesen zu dürfen. */
export async function pruefeLesezugriff(azubiId: string): Promise<void> {
const benutzer = await erfordereBenutzer();
erfordereZugriffAufAzubi(benutzer, azubiId);
}
+115
View File
@@ -0,0 +1,115 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import { staendeVon } from '@/lib/repos/aufgaben';
import { alsZeitpunktText } from '@/lib/zeit';
import { StatusMarke } from './StatusMarke';
export const metadata: Metadata = { title: 'Praxisaufgaben' };
export const dynamic = 'force-dynamic';
export default async function AufgabenListe() {
const benutzer = await erfordereBenutzer();
const alle = inhalte();
if (benutzer.rolle === 'ausbilder') {
const meine = ausbildungenVonAusbilder(benutzer.id);
return (
<div className="stapel">
<div>
<h1>Praxisaufgaben</h1>
<p className="leise">
{alle.aufgaben.length} Aufgaben · Stand deiner {meine.length === 1 ? 'Auszubildenden' : 'Auszubildenden'}
</p>
</div>
{meine.length === 0 ? (
<p className="hinweis hinweis-warnung">
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
meine.map(({ azubi }) => {
const staende = staendeVon(azubi.id);
const wartend = alle.aufgaben.filter((a) => staende.get(a.id)?.status === 'zur_abnahme');
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>
{wartend.length > 0 ? (
<span className="marke-status" data-ton="warnung">
● {wartend.length} {wartend.length === 1 ? 'Abnahme' : 'Abnahmen'} offen
</span>
) : (
<span className="leise">nichts zur Abnahme</span>
)}
</div>
<ul className="aufgabenliste">
{alle.aufgaben.map((aufgabe) => {
const stand = staende.get(aufgabe.id);
return (
<li key={aufgabe.id}>
<Link href={`/aufgaben/${aufgabe.id}?azubi=${azubi.id}`} className="aufgabenzeile">
<span className="aufgaben-nummer">{aufgabe.id}</span>
<span className="aufgaben-titel">
{aufgabe.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
Woche {aufgabe.woche} · {aufgabe.zeitrahmen}
{stand?.abgegebenAm ? ` · abgegeben ${alsZeitpunktText(stand.abgegebenAm)}` : ''}
</span>
</span>
<StatusMarke status={stand?.status ?? 'offen'} />
</Link>
</li>
);
})}
</ul>
</section>
);
})
)}
</div>
);
}
// --- Sicht des Azubis ---
const staende = staendeVon(benutzer.id);
const abgenommen = alle.aufgaben.filter((a) => staende.get(a.id)?.status === 'abgenommen').length;
return (
<div className="stapel">
<div>
<h1>Praxisaufgaben</h1>
<p className="leise">
{abgenommen} von {alle.aufgaben.length} abgenommen
</p>
</div>
<ul className="aufgabenliste">
{alle.aufgaben.map((aufgabe) => {
const stand = staende.get(aufgabe.id);
return (
<li key={aufgabe.id}>
<Link href={`/aufgaben/${aufgabe.id}`} className="aufgabenzeile">
<span className="aufgaben-nummer">{aufgabe.id}</span>
<span className="aufgaben-titel">
{aufgabe.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
Woche {aufgabe.woche} · {aufgabe.zeitrahmen} · {aufgabe.kriterien.length} Kriterien
</span>
</span>
<StatusMarke status={stand?.status ?? 'offen'} />
</Link>
</li>
);
})}
</ul>
</div>
);
}
+416
View File
@@ -0,0 +1,416 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { eigenerStand, rahmenplanabdeckung } from '@/lib/auswertung/sammeln';
import { bilanz, nachGruppen, stufenText, type Abdeckungsstufe } from '@/lib/auswertung/abdeckung';
import { verlauf } from '@/lib/kontrollen/bewertung';
import { Druckknopf } from '@/komponenten/Druckknopf';
export const metadata: Metadata = { title: 'Auswertung' };
export const dynamic = 'force-dynamic';
const stufenTon: Record<Abdeckungsstufe, string> = {
abgedeckt: 'gut',
angefangen: 'warnung',
offen: 'neutral',
ohne_inhalt: 'fehler',
};
const stufenZeichen: Record<Abdeckungsstufe, string> = {
abgedeckt: '✓',
angefangen: '◐',
offen: '○',
ohne_inhalt: '!',
};
export default async function AuswertungsSeite({ searchParams }: { searchParams: Promise<{ azubi?: string }> }) {
const benutzer = await erfordereBenutzer();
const { azubi: azubiParameter } = await searchParams;
if (benutzer.rolle === 'azubi') {
return <AzubiAuswertung azubiId={benutzer.id} name={benutzer.name} />;
}
const meine = ausbildungenVonAusbilder(benutzer.id);
if (meine.length === 0) {
return (
<div className="stapel">
<h1>Auswertung</h1>
<p className="hinweis hinweis-warnung">
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
</div>
);
}
const azubiId = azubiParameter ?? meine[0]!.azubi.id;
erfordereSichtAufAzubi(benutzer, azubiId);
const azubi = benutzerNachId(azubiId)!;
return (
<div className="stapel">
<div>
<h1>Auswertung</h1>
<p className="leise">{azubi.name}</p>
</div>
{meine.length > 1 ? (
<nav className="reihe nicht-drucken" aria-label="Azubi wählen">
{meine.map(({ azubi: person }) => (
<Link
key={person.id}
href={`/auswertung?azubi=${person.id}`}
className={`knopf ${person.id === azubiId ? '' : 'knopf-zweit'}`}
>
{person.name}
</Link>
))}
</nav>
) : null}
<Rahmenplan azubiId={azubiId} />
<Kontrollverlauf azubiId={azubiId} />
<Lernfelder azubiId={azubiId} />
{/* Die Abdeckung ist die Ansicht, die man zum Gespräch mitnimmt – und zwar
auf Papier, weil dort niemand scrollt. */}
<div className="reihe nicht-drucken">
<Druckknopf beschriftung="Auswertung drucken" />
</div>
</div>
);
}
// --- Sicht des Azubis ------------------------------------------------------
function AzubiAuswertung({ azubiId, name }: { azubiId: string; name: string }) {
const ausbildung = ausbildungNachAzubi(azubiId);
const stand = eigenerStand(azubiId, ausbildung?.beginn ?? null);
return (
<div className="stapel">
<div>
<h1>Dein Stand</h1>
<p className="leise">{name}</p>
</div>
<div className="raster">
<Kachel
titel="Handouts gelesen"
wert={`${stand.handouts.erledigt} von ${stand.handouts.gesamt}`}
anteil={anteil(stand.handouts.erledigt, stand.handouts.gesamt)}
fuss={`${stand.handouts.gesamt - stand.handouts.erledigt} noch offen`}
/>
<Kachel
titel="Merkkarten, die sitzen"
wert={`${stand.karten.erledigt} von ${stand.karten.gesamt}`}
anteil={anteil(stand.karten.erledigt, stand.karten.gesamt)}
fuss={stand.karten.faellig > 0 ? `${stand.karten.faellig} heute fällig` : 'heute nichts fällig'}
/>
<Kachel
titel="Praxisaufgaben abgenommen"
wert={`${stand.aufgaben.erledigt} von ${stand.aufgaben.gesamt}`}
anteil={anteil(stand.aufgaben.erledigt, stand.aufgaben.gesamt)}
fuss={
stand.aufgaben.zurAbnahme > 0
? `${stand.aufgaben.zurAbnahme} wartet auf Abnahme`
: `${stand.aufgaben.offen} noch offen`
}
/>
<Kachel
titel="Berichtsheft abgezeichnet"
wert={`${stand.berichtsheft.abgezeichnet} von ${stand.berichtsheft.gesamt}`}
anteil={anteil(stand.berichtsheft.abgezeichnet, stand.berichtsheft.gesamt)}
fuss={
stand.berichtsheft.fehlendeWochen > 0
? `${stand.berichtsheft.fehlendeWochen} Wochen ohne Blatt`
: stand.berichtsheft.eingereicht > 0
? `${stand.berichtsheft.eingereicht} beim Ausbilder`
: 'nichts offen'
}
ton={stand.berichtsheft.fehlendeWochen > 0 ? 'fehler' : undefined}
/>
</div>
{stand.berichtsheft.fehlendeWochen > 0 ? (
<p className="hinweis hinweis-warnung">
<strong>
{stand.berichtsheft.fehlendeWochen}{' '}
{stand.berichtsheft.fehlendeWochen === 1 ? 'Woche' : 'Wochen'} ohne Berichtsheftblatt.
</strong>{' '}
Ohne geführten Nachweis gibt es keine Zulassung zur Abschlussprüfung.{' '}
<Link href="/berichtsheft">Jetzt nachtragen</Link>.
</p>
) : null}
<ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} />
</div>
);
}
function Kachel({
titel,
wert,
anteil,
fuss,
ton,
}: {
titel: string;
wert: string;
anteil: number;
fuss: string;
ton?: string;
}) {
return (
<section className="karte stapel-eng">
<h2 style={{ fontSize: '0.9rem', marginBottom: 0, color: 'var(--text-leise)' }}>{titel}</h2>
<strong style={{ fontSize: '1.5rem' }}>{wert}</strong>
<div className="balken">
<span style={{ width: `${anteil}%` }} />
</div>
<span className="leise" style={{ color: ton === 'fehler' ? 'var(--fehler)' : undefined }}>
{fuss}
</span>
</section>
);
}
// --- Rahmenplanabdeckung ---------------------------------------------------
function Rahmenplan({ azubiId }: { azubiId: string }) {
const { positionen, gruppennamen } = rahmenplanabdeckung(azubiId);
const gruppen = nachGruppen(positionen);
const gesamt = bilanz(positionen);
return (
<section className="stapel" aria-labelledby="rahmenplan">
<div>
<h2 id="rahmenplan" style={{ marginBottom: '0.2rem' }}>
Abdeckung des Ausbildungsrahmenplans
</h2>
<p className="leise" style={{ margin: 0 }}>
Die Ansicht, die im Zweifel gegenüber der IHK zählt. Als <strong>vermittelt</strong> gilt eine
Position, wenn es dazu eine abgenommene Praxisaufgabe oder einen abgeschlossenen
Checklisten-Durchlauf gibt – oder mindestens zwei gelesene Handouts.
</p>
</div>
<div className="karte-grund stapel-eng">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<strong>
{gesamt.abgedeckt} von {gesamt.gesamt} Positionen vermittelt
</strong>
<span className="leise">{gesamt.prozent} %</span>
</div>
<div className="balken" style={{ height: '14px' }}>
<span style={{ width: `${gesamt.prozent}%` }} />
</div>
<div className="reihe" style={{ gap: '0.5rem' }}>
<span className="marke-status" data-ton="gut">
✓ {gesamt.abgedeckt} vermittelt
</span>
<span className="marke-status" data-ton="warnung">
◐ {gesamt.angefangen} angefangen
</span>
<span className="marke-status">○ {gesamt.offen} geplant</span>
{gesamt.ohneInhalt > 0 ? (
<span className="marke-status" data-ton="fehler">
! {gesamt.ohneInhalt} ohne Inhalt
</span>
) : null}
</div>
</div>
{gesamt.ohneInhalt > 0 ? (
<p className="hinweis hinweis-warnung">
<strong>
Zu {gesamt.ohneInhalt} {gesamt.ohneInhalt === 1 ? 'Position' : 'Positionen'} gibt es im Lehrgang
überhaupt keinen Inhalt.
</strong>{' '}
Das ist keine Lücke beim Azubi, sondern eine im Lehrgang – dort hilft kein Lernen, sondern nur ein
neues Handout oder eine neue Aufgabe unter <code>content/</code>.
</p>
) : null}
{gruppen.map((gruppe) => (
<details key={gruppe.gruppe} className="abdeckungsgruppe karte" open={gruppe.bilanz.ohneInhalt > 0}>
<summary>
<strong>{gruppennamen[gruppe.gruppe] ?? gruppe.gruppe}</strong>
<span className="leise">
{' '}
– {gruppe.bilanz.abgedeckt} von {gruppe.bilanz.gesamt} vermittelt
</span>
</summary>
<ul className="abdeckungsliste">
{gruppe.positionen.map((position) => (
<li key={position.id} data-stufe={position.stufe}>
<span className="abdeckung-kuerzel">{position.id}</span>
<span className="abdeckung-titel">
{position.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{position.verordnung}
{position.geplant > 0
? ` · ${position.nachweise.filter((n) => n.erledigt).length} von ${position.geplant} Nachweisen`
: ''}
</span>
{position.nachweise.length > 0 ? (
<span className="nachweisliste">
{position.nachweise.map((nachweis) => (
<span
key={`${nachweis.art}-${nachweis.id}`}
className="nachweis"
data-erledigt={nachweis.erledigt ? 'ja' : 'nein'}
title={`${nachweis.titel}${nachweis.erledigtAm ? ` – ${nachweis.erledigtAm}` : ''}`}
>
{nachweis.erledigt ? '✓' : '○'} {nachweis.id}
</span>
))}
</span>
) : null}
</span>
<span className="marke-status" data-ton={stufenTon[position.stufe]}>
{stufenZeichen[position.stufe]} {stufenText[position.stufe]}
</span>
</li>
))}
</ul>
</details>
))}
</section>
);
}
// --- Lernfelder ------------------------------------------------------------
function Lernfelder({ azubiId }: { azubiId: string }) {
const { lernfelder } = rahmenplanabdeckung(azubiId);
const jahre = [1, 2, 3];
return (
<section className="stapel" aria-labelledby="lernfelder">
<div>
<h2 id="lernfelder" style={{ marginBottom: '0.2rem' }}>
Lernfelder der Berufsschule
</h2>
<p className="leise" style={{ margin: 0 }}>
Nach derselben Regel bewertet. Der Unterricht selbst findet in der Schule statt – hier steht, was im
Betrieb dazu angekommen ist.
</p>
</div>
{jahre.map((jahr) => {
const eigene = lernfelder.filter((l) => l.jahr === jahr);
if (eigene.length === 0) return null;
return (
<div key={jahr} className="karte stapel-eng">
<h3 style={{ fontSize: '0.95rem', marginBottom: 0 }}>{jahr}. Ausbildungsjahr</h3>
<ul className="abdeckungsliste">
{eigene.map((lernfeld) => (
<li key={lernfeld.id} data-stufe={lernfeld.stufe}>
<span className="abdeckung-kuerzel">{lernfeld.id}</span>
<span className="abdeckung-titel">
{lernfeld.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{lernfeld.stunden} Stunden
</span>
</span>
<span className="marke-status" data-ton={stufenTon[lernfeld.stufe]}>
{stufenZeichen[lernfeld.stufe]} {stufenText[lernfeld.stufe]}
</span>
</li>
))}
</ul>
</div>
);
})}
</section>
);
}
// --- Ergebnisverlauf -------------------------------------------------------
function Kontrollverlauf({ azubiId }: { azubiId: string }) {
const ausbildung = ausbildungNachAzubi(azubiId);
const stand = eigenerStand(azubiId, ausbildung?.beginn ?? null);
return <ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} />;
}
function ErgebnisTabelle({
ergebnisse,
}: {
ergebnisse: {
id: string;
titel: string;
woche: number;
prozent: number;
note: number;
notentext: string;
erreicht: number;
moeglich: number;
korrigiertAm: string;
}[];
}) {
const stand = verlauf(ergebnisse);
return (
<section className="stapel" aria-labelledby="verlauf">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="verlauf" style={{ margin: 0 }}>
Lernzielkontrollen
</h2>
{stand.anzahl > 0 ? (
<span className="leise">
Schnitt {stand.schnittProzent} % · Note {stand.schnittNote.toString().replace('.', ',')} · beste{' '}
{stand.beste} %
</span>
) : null}
</div>
{ergebnisse.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch keine korrigierte Lernzielkontrolle.
</p>
) : (
<ol className="verlaufsliste">
{ergebnisse.map((ergebnis) => (
<li key={ergebnis.id}>
<span className="kontroll-woche">Woche {ergebnis.woche}</span>
<span className="verlauf-titel">
{ergebnis.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{zahl(ergebnis.erreicht)} von {zahl(ergebnis.moeglich)} Punkten · korrigiert{' '}
{ergebnis.korrigiertAm}
</span>
</span>
<span className="verlauf-balken">
<span className="balken">
<span style={{ width: `${ergebnis.prozent}%` }} />
</span>
<span className="leise">{ergebnis.prozent} %</span>
</span>
<span className={`note-anzeige note-klein note-${ergebnis.note}`} title={ergebnis.notentext}>
{ergebnis.note}
</span>
</li>
))}
</ol>
)}
</section>
);
}
function anteil(erledigt: number, gesamt: number): number {
if (gesamt <= 0) return 0;
return Math.round((erledigt / gesamt) * 100);
}
function zahl(wert: number): string {
return wert.toString().replace('.', ',');
}
@@ -0,0 +1,18 @@
import type { BerichtStatus } from '@/lib/db/schema';
/** Zustand eines Wochenblatts – nie nur über die Farbe, immer mit Zeichen. */
export const berichtsStatusText: Record<BerichtStatus, { text: string; ton: string; zeichen: string }> = {
entwurf: { text: 'Entwurf', ton: 'neutral', zeichen: '○' },
eingereicht: { text: 'eingereicht', ton: 'warnung', zeichen: '▲' },
zurueckgegeben: { text: 'zurückgegeben', ton: 'fehler', zeichen: '↩' },
abgezeichnet: { text: 'abgezeichnet', ton: 'gut', zeichen: '✓' },
};
export function BerichtsMarke({ status }: { status: BerichtStatus }) {
const marke = berichtsStatusText[status];
return (
<span className="marke-status" data-ton={marke.ton}>
{marke.zeichen} {marke.text}
</span>
);
}
@@ -0,0 +1,380 @@
'use client';
import { useActionState, useState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { blattEinreichen, blattSpeichern, type Rueckmeldung } from './aktionen';
import { pruefeWochenblatt, summeStunden, type Wochenblatt } from '@/lib/berichtsheft/pruefung';
import type { Vorschlag } from '@/lib/berichtsheft/vorschlaege';
/**
* Das Wochenblatt zum Ausfüllen.
*
* Die Prüfung läuft im Browser mit, während getippt wird – dieselbe Funktion,
* die auch serverseitig verwendet wird. Sie warnt, sie sperrt nicht: Der Knopf
* zum Einreichen bleibt immer bedienbar.
*/
export function Blattformular({
berichtId,
anfangsblatt,
vorschlaege,
status,
}: {
berichtId: string;
anfangsblatt: Wochenblatt;
vorschlaege: Vorschlag[];
status: string;
}) {
const [blatt, setzeBlatt] = useState<Wochenblatt>(() => ({
betrieb: anfangsblatt.betrieb.length > 0 ? anfangsblatt.betrieb : [{ text: '', stunden: 0 }],
schule: anfangsblatt.schule.length > 0 ? anfangsblatt.schule : [{ lernfeld: '', themen: '', stunden: 0 }],
sonstiges: anfangsblatt.sonstiges,
}));
const [uebernommen, setzeUebernommen] = useState<string[]>([]);
const [speicherZustand, speichern] = useActionState<Rueckmeldung, FormData>(blattSpeichern, {});
const [einreichZustand, einreichen] = useActionState<Rueckmeldung, FormData>(blattEinreichen, {});
const gesperrt = status === 'abgezeichnet';
const warnungen = pruefeWochenblatt(blatt);
const ernste = warnungen.filter((w) => w.schwere === 'warnung');
function betriebAendern(stelle: number, teil: Partial<{ text: string; stunden: number }>) {
setzeBlatt((v) => ({
...v,
betrieb: v.betrieb.map((e, i) => (i === stelle ? { ...e, ...teil } : e)),
}));
}
function schuleAendern(stelle: number, teil: Partial<{ lernfeld: string; themen: string; stunden: number }>) {
setzeBlatt((v) => ({
...v,
schule: v.schule.map((e, i) => (i === stelle ? { ...e, ...teil } : e)),
}));
}
function uebernehmen(vorschlag: Vorschlag) {
setzeUebernommen((v) => [...v, vorschlag.id]);
if (vorschlag.ziel === 'schule') {
setzeBlatt((v) => {
const leer = v.schule.findIndex((e) => e.lernfeld === '' && e.themen === '');
const neu = { lernfeld: vorschlag.herkunft, themen: vorschlag.text, stunden: vorschlag.stunden };
return {
...v,
schule: leer >= 0 ? v.schule.map((e, i) => (i === leer ? neu : e)) : [...v.schule, neu],
};
});
return;
}
setzeBlatt((v) => {
const leer = v.betrieb.findIndex((e) => e.text.trim() === '');
const neu = { text: vorschlag.text, stunden: vorschlag.stunden };
return {
...v,
betrieb: leer >= 0 ? v.betrieb.map((e, i) => (i === leer ? neu : e)) : [...v.betrieb, neu],
};
});
}
const offeneVorschlaege = vorschlaege.filter((v) => !uebernommen.includes(v.id));
return (
<div className="stapel">
{!gesperrt && offeneVorschlaege.length > 0 ? (
<section className="karte stapel-eng nicht-drucken" aria-labelledby="vorschlaege">
<div>
<h2 id="vorschlaege" style={{ marginBottom: '0.15rem', fontSize: '1rem' }}>
Vorschläge aus dieser Woche
</h2>
<p className="leise" style={{ margin: 0 }}>
Übernehmen und dann <strong>in eigene Worte bringen</strong>. Ein wörtlich übernommener Satz ist kein
Ausbildungsnachweis, und die Stundenangabe ist nur ein Anhaltspunkt.
</p>
</div>
<ul className="vorschlagsliste">
{offeneVorschlaege.map((vorschlag) => (
<li key={vorschlag.id}>
<button type="button" className="vorschlag" onClick={() => uebernehmen(vorschlag)}>
<span className="vorschlag-quelle" data-quelle={vorschlag.quelle}>
{beschriftung[vorschlag.quelle]}
</span>
<span className="vorschlag-text">{vorschlag.text}</span>
<span className="leise">
{vorschlag.herkunft}
{vorschlag.stunden > 0 ? ` · ${zahlText(vorschlag.stunden)} h` : ''}
</span>
<span className="vorschlag-plus" aria-hidden="true">
+
</span>
</button>
</li>
))}
</ul>
</section>
) : null}
<form action={speichern} className="stapel" id="blattformular">
<input type="hidden" name="berichtId" value={berichtId} />
{/* --- Betriebliche Tätigkeiten --- */}
<section className="stapel-eng" aria-labelledby="betrieb">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="betrieb" style={{ margin: 0 }}>
Betriebliche Tätigkeiten und Unterweisungen
</h2>
<span className="leise">{zahlText(summeStunden({ ...blatt, schule: [] }))} h</span>
</div>
{blatt.betrieb.map((eintrag, stelle) => (
<div key={stelle} className="blattzeile">
<div className="blattzeile-text">
<label htmlFor={`betrieb-${stelle}`} className="nur-lesehilfe">
Tätigkeit {stelle + 1}
</label>
<textarea
id={`betrieb-${stelle}`}
name="betriebText"
rows={2}
maxLength={600}
value={eintrag.text}
disabled={gesperrt}
onChange={(e) => betriebAendern(stelle, { text: e.target.value })}
placeholder="Was hast du gemacht? Gerät oder Dienst nennen, Tätigkeit, Ergebnis."
aria-invalid={warnungen.some((w) => w.feld === `betrieb.${stelle}` && w.schwere === 'warnung')}
/>
<Feldwarnungen warnungen={warnungen} feld={`betrieb.${stelle}`} />
</div>
<div className="blattzeile-stunden">
<label htmlFor={`betrieb-std-${stelle}`}>Std.</label>
<input
id={`betrieb-std-${stelle}`}
name="betriebStunden"
type="number"
min={0}
max={60}
step={0.25}
value={eintrag.stunden || ''}
disabled={gesperrt}
onChange={(e) => betriebAendern(stelle, { stunden: Number(e.target.value) || 0 })}
/>
</div>
{!gesperrt ? (
<button
type="button"
className="knopf knopf-still zeile-weg"
onClick={() => setzeBlatt((v) => ({ ...v, betrieb: v.betrieb.filter((_, i) => i !== stelle) }))}
title="Zeile entfernen"
>
<span aria-hidden="true">×</span>
<span className="nur-lesehilfe">Tätigkeit {stelle + 1} entfernen</span>
</button>
) : null}
</div>
))}
{!gesperrt ? (
<div>
<button
type="button"
className="knopf knopf-zweit"
onClick={() => setzeBlatt((v) => ({ ...v, betrieb: [...v.betrieb, { text: '', stunden: 0 }] }))}
>
Zeile hinzufügen
</button>
</div>
) : null}
</section>
{/* --- Berufsschule --- */}
<section className="stapel-eng" aria-labelledby="schule">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="schule" style={{ margin: 0 }}>
Berufsschule
</h2>
<span className="leise">{zahlText(summeStunden({ ...blatt, betrieb: [] }))} h</span>
</div>
{blatt.schule.map((eintrag, stelle) => (
<div key={stelle} className="blattzeile">
<div className="blattzeile-lernfeld">
<label htmlFor={`lf-${stelle}`}>Lernfeld</label>
<input
id={`lf-${stelle}`}
name="schuleLernfeld"
type="text"
maxLength={10}
value={eintrag.lernfeld}
disabled={gesperrt}
onChange={(e) => schuleAendern(stelle, { lernfeld: e.target.value })}
placeholder="LF3"
/>
</div>
<div className="blattzeile-text">
<label htmlFor={`themen-${stelle}`} className="nur-lesehilfe">
Themen
</label>
<textarea
id={`themen-${stelle}`}
name="schuleThemen"
rows={2}
maxLength={600}
value={eintrag.themen}
disabled={gesperrt}
onChange={(e) => schuleAendern(stelle, { themen: e.target.value })}
placeholder="Themen des Unterrichts"
/>
<Feldwarnungen warnungen={warnungen} feld={`schule.${stelle}`} />
</div>
<div className="blattzeile-stunden">
<label htmlFor={`schule-std-${stelle}`}>Std.</label>
<input
id={`schule-std-${stelle}`}
name="schuleStunden"
type="number"
min={0}
max={60}
step={0.25}
value={eintrag.stunden || ''}
disabled={gesperrt}
onChange={(e) => schuleAendern(stelle, { stunden: Number(e.target.value) || 0 })}
/>
</div>
{!gesperrt ? (
<button
type="button"
className="knopf knopf-still zeile-weg"
onClick={() => setzeBlatt((v) => ({ ...v, schule: v.schule.filter((_, i) => i !== stelle) }))}
title="Zeile entfernen"
>
<span aria-hidden="true">×</span>
<span className="nur-lesehilfe">Schulzeile {stelle + 1} entfernen</span>
</button>
) : null}
</div>
))}
{!gesperrt ? (
<div>
<button
type="button"
className="knopf knopf-zweit"
onClick={() =>
setzeBlatt((v) => ({ ...v, schule: [...v.schule, { lernfeld: '', themen: '', stunden: 0 }] }))
}
>
Zeile hinzufügen
</button>
</div>
) : null}
</section>
{/* --- Sonstiges --- */}
<section className="stapel-eng" aria-labelledby="sonstiges">
<h2 id="sonstiges" style={{ margin: 0 }}>
Sonstiges
</h2>
<label htmlFor="sonstiges-feld" className="nur-lesehilfe">
Sonstiges
</label>
<textarea
id="sonstiges-feld"
name="sonstiges"
rows={3}
maxLength={2000}
value={blatt.sonstiges}
disabled={gesperrt}
onChange={(e) => setzeBlatt((v) => ({ ...v, sonstiges: e.target.value }))}
placeholder="Urlaub, Krankheit, Feiertage, Betriebsversammlung – alles, was keine Tätigkeit ist."
/>
<Feldwarnungen warnungen={warnungen} feld="sonstiges" />
</section>
{/* --- Summe und Prüfung --- */}
<section className="karte stapel-eng" aria-labelledby="pruefung">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="pruefung" style={{ margin: 0, fontSize: '1rem' }}>
Prüfung
</h2>
<strong>{zahlText(summeStunden(blatt))} Stunden gesamt</strong>
</div>
{warnungen.length === 0 ? (
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
✓ Nichts zu beanstanden.
</p>
) : (
<ul className="warnliste">
{warnungen.map((warnung, stelle) => (
<li key={stelle} data-schwere={warnung.schwere}>
<span className="warnzeichen" aria-hidden="true">
{warnung.schwere === 'warnung' ? '!' : 'i'}
</span>
<span>
{warnung.text}
{warnung.auszug ? <em> &ndash; &bdquo;{warnung.auszug}&ldquo;</em> : null}
</span>
</li>
))}
</ul>
)}
<p className="leise" style={{ marginBottom: 0 }}>
Das sind Hinweise, keine Sperre. Du kannst jederzeit einreichen.
</p>
</section>
<Meldung zustand={speicherZustand} />
{!gesperrt ? (
<div className="reihe nicht-drucken">
<Absenden className="knopf knopf-zweit">Speichern</Absenden>
<button type="submit" className="knopf knopf-akzent" formAction={einreichen}>
{ernste.length > 0 ? `Trotz ${ernste.length} Warnungen einreichen` : 'Einreichen'}
</button>
</div>
) : null}
<Meldung zustand={einreichZustand} />
</form>
</div>
);
}
function Feldwarnungen({
warnungen,
feld,
}: {
warnungen: ReturnType<typeof pruefeWochenblatt>;
feld: string;
}) {
const eigene = warnungen.filter((w) => w.feld === feld);
if (eigene.length === 0) return null;
return (
<ul className="feldwarnung">
{eigene.map((warnung, stelle) => (
<li key={stelle} data-schwere={warnung.schwere}>
{warnung.text}
</li>
))}
</ul>
);
}
const beschriftung: Record<string, string> = {
tagesnotiz: 'Tagesnotiz',
aufgabe: 'Aufgabe',
checkliste: 'Checkliste',
handout: 'Lerninhalt',
};
function zahlText(wert: number): string {
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
}
@@ -0,0 +1,74 @@
import { gegenueberstellungen, regeln, verbgruppen } from '@/lib/berichtsheft/formulierung';
/**
* Formulierungshilfe: Tätigkeitsverben und die Gegenüberstellung
* „so besser nicht / so ist es richtig".
*
* Eingeklappt, weil sie beim zweiten Wochenblatt nur noch stört – aber sie
* bleibt griffbereit, statt in einer Anleitung zu verschwinden.
*/
export function Formulierungshilfe() {
return (
<details className="karte formulierungshilfe nicht-drucken">
<summary>
<strong>Formulierungshilfe</strong>
<span className="leise"> – Tätigkeitsverben und Beispiele</span>
</summary>
<div className="stapel" style={{ marginTop: '0.9rem' }}>
<section aria-labelledby="regeln">
<h3 id="regeln" style={{ fontSize: '0.95rem' }}>
Sechs Regeln
</h3>
<ol className="stapel-eng" style={{ margin: 0, paddingLeft: '1.2rem' }}>
{regeln.map((regel, stelle) => (
<li key={stelle}>{regel}</li>
))}
</ol>
</section>
<section aria-labelledby="verben">
<h3 id="verben" style={{ fontSize: '0.95rem' }}>
Tätigkeitsverben
</h3>
<div className="verbgruppen">
{verbgruppen.map((gruppe) => (
<div key={gruppe.titel}>
<span className="wochentag-marke">{gruppe.titel}</span>
<p style={{ margin: 0 }}>{gruppe.verben.join(' · ')}</p>
</div>
))}
</div>
</section>
<section aria-labelledby="beispiele">
<h3 id="beispiele" style={{ fontSize: '0.95rem' }}>
So besser nicht – so ist es richtig
</h3>
<ul className="gegenueberstellung">
{gegenueberstellungen.map((beispiel, stelle) => (
<li key={stelle}>
<p className="schlecht">
<span className="marke-status" data-ton="fehler">
✗ so nicht
</span>{' '}
{beispiel.schlecht}
</p>
<p className="gut">
<span className="marke-status" data-ton="gut">
✓ besser
</span>{' '}
{beispiel.gut}
</p>
<p className="leise" style={{ margin: 0 }}>
{beispiel.warum}
</p>
</li>
))}
</ul>
</section>
</div>
</details>
);
}
@@ -0,0 +1,80 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { tagesnotizSpeichern, type Rueckmeldung } from './aktionen';
export type Tagesfeld = {
datum: string;
wochentag: string;
datumText: string;
text: string;
stunden: number;
istHeute: boolean;
};
/**
* Die täglichen Stichworte.
*
* Der Zweck: zwei Minuten am Tag statt einer Stunde Rekonstruktion am Freitag.
* Deshalb je Tag ein eigenes kleines Formular, das für sich allein absendet –
* ein großes Formular für die ganze Woche würde bei jedem Speichern alles
* neu schreiben und wäre am Handy unbrauchbar.
*/
export function Tagesnotizen({ tage }: { tage: Tagesfeld[] }) {
return (
<div className="tagesnotizen">
{tage.map((tag) => (
<Tagesnotiz key={tag.datum} tag={tag} />
))}
</div>
);
}
function Tagesnotiz({ tag }: { tag: Tagesfeld }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(tagesnotizSpeichern, {});
return (
<form action={aktion} className={`tagesnotiz${tag.istHeute ? ' tagesnotiz-heute' : ''}`}>
<input type="hidden" name="datum" value={tag.datum} />
<div className="tagesnotiz-kopf">
<strong>{tag.wochentag}</strong>
<span className="leise">{tag.datumText}</span>
{tag.istHeute ? (
<span className="marke-status" data-ton="warnung">
● heute
</span>
) : null}
</div>
<label htmlFor={`notiz-${tag.datum}`} className="nur-lesehilfe">
Stichworte für {tag.wochentag}
</label>
<textarea
id={`notiz-${tag.datum}`}
name="text"
rows={2}
maxLength={2000}
defaultValue={tag.text}
placeholder="Stichworte genügen – woran hast du heute gearbeitet?"
/>
<div className="tagesnotiz-fuss">
<label htmlFor={`std-${tag.datum}`}>Stunden</label>
<input
id={`std-${tag.datum}`}
name="stunden"
type="number"
min={0}
max={24}
step={0.25}
defaultValue={tag.stunden || ''}
/>
<Absenden className="knopf knopf-still">Merken</Absenden>
</div>
<Meldung zustand={zustand} />
</form>
);
}
@@ -0,0 +1,56 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { blattAbzeichnen, blattZurueckgeben, type Rueckmeldung } from '../aktionen';
/**
* Abzeichnen oder zurückgeben.
*
* Zurückgeben verlangt eine Anmerkung – ohne sie weiß der Azubi nicht, was er
* ändern soll, und das Blatt pendelt hin und her.
*/
export function Abzeichnen({
berichtId,
anmerkung,
status,
}: {
berichtId: string;
anmerkung: string;
status: string;
}) {
const [abzeichenZustand, abzeichnen] = useActionState<Rueckmeldung, FormData>(blattAbzeichnen, {});
const [rueckgabeZustand, zurueckgeben] = useActionState<Rueckmeldung, FormData>(blattZurueckgeben, {});
if (status === 'abgezeichnet') return null;
return (
<form action={abzeichnen} className="stapel-eng">
<input type="hidden" name="berichtId" value={berichtId} />
<Meldung zustand={abzeichenZustand} />
<Meldung zustand={rueckgabeZustand} />
<label htmlFor="anmerkung">Anmerkung an den Azubi</label>
<textarea
id="anmerkung"
name="anmerkung"
rows={3}
maxLength={2000}
defaultValue={anmerkung}
placeholder="Beim Abzeichnen freiwillig, beim Zurückgeben nötig: Was soll er ändern?"
/>
<div className="reihe">
<Absenden className="knopf knopf-akzent">Abzeichnen</Absenden>
<button type="submit" className="knopf knopf-zweit" formAction={zurueckgeben}>
Zur Nacharbeit zurückgeben
</button>
</div>
<p className="leise" style={{ marginBottom: 0 }}>
Abzeichnen sperrt das Blatt endgültig – der Azubi kann es danach nicht mehr ändern.
</p>
</form>
);
}
@@ -0,0 +1,296 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import { alsWochenblatt, notizenIm } from '@/lib/repos/berichtsheft';
import { staendeVon as aufgabenStaende } from '@/lib/repos/aufgaben';
import { laeufeVon } from '@/lib/repos/checklisten';
import { gelesenSet } from '@/lib/repos/fortschritt';
import { planVon } from '@/lib/repos/wochenplanung';
import { baueVorschlaege, type Rohdaten } from '@/lib/berichtsheft/vorschlaege';
import { pruefeWochenblatt, summeBetrieb, summeSchule, summeStunden } from '@/lib/berichtsheft/pruefung';
import { alsDatumText, alsZeitpunktText, kalenderwocheText } from '@/lib/zeit';
import { Blattformular } from '../Blattformular';
import { Formulierungshilfe } from '../Formulierungshilfe';
import { BerichtsMarke } from '../BerichtsMarke';
import { Abzeichnen } from './Abzeichnen';
import { blattZurueckziehen, blattZumLesen } from '../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ berichtId: string }> }): Promise<Metadata> {
const { berichtId } = await params;
const gelesen = await blattZumLesen(berichtId);
return { title: gelesen ? `Wochenblatt Nr. ${gelesen.bericht.nr}` : 'Wochenblatt' };
}
export default async function BlattSeite({ params }: { params: Promise<{ berichtId: string }> }) {
const { berichtId } = await params;
// Rechteprüfung steckt hier drin: kein Zugriff bedeutet null bedeutet 404.
const gelesen = await blattZumLesen(berichtId);
if (!gelesen) notFound();
const { benutzer, bericht } = gelesen;
const eigenes = bericht.benutzerId === benutzer.id;
const azubi = eigenes ? benutzer : benutzerNachId(bericht.benutzerId);
if (!azubi) notFound();
const blatt = alsWochenblatt(bericht);
const warnungen = pruefeWochenblatt(blatt);
const vorschlaege = eigenes ? sammleVorschlaege(azubi.id, bericht.von, bericht.bis) : [];
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/berichtsheft">Berichtsheft</Link>
{!eigenes ? ` › ${azubi.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="blatt-nr blatt-nr-gross">Nr. {bericht.nr}</span>
<BerichtsMarke status={bericht.status} />
<span className="leise">{kalenderwocheText(bericht.von)}</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
</h1>
<p className="leise">
{azubi.name}
{bericht.eingereichtAm ? ` · eingereicht ${alsZeitpunktText(bericht.eingereichtAm)}` : ''}
</p>
</header>
{bericht.status === 'abgezeichnet' ? (
<p className="hinweis hinweis-gut">
✓ Abgezeichnet am {alsZeitpunktText(bericht.abgezeichnetAm)}
{bericht.abgezeichnetVon ? ` von ${benutzerNachId(bericht.abgezeichnetVon)?.name ?? 'dem Ausbilder'}` : ''}.
Das Blatt ist gesperrt.
</p>
) : null}
{bericht.status === 'zurueckgegeben' && bericht.anmerkung ? (
<div className="hinweis hinweis-warnung">
<strong>Zur Nacharbeit zurückgegeben:</strong>
<p className="notiz-anzeige" style={{ marginTop: '0.4rem' }}>
{bericht.anmerkung}
</p>
</div>
) : null}
{bericht.status === 'abgezeichnet' && bericht.anmerkung ? (
<div className="karte">
<strong>Anmerkung des Ausbilders</strong>
<p className="notiz-anzeige" style={{ marginTop: '0.4rem' }}>
{bericht.anmerkung}
</p>
</div>
) : null}
{eigenes ? (
<Blattformular
berichtId={bericht.id}
anfangsblatt={blatt}
vorschlaege={vorschlaege}
status={bericht.status}
/>
) : (
<AnsichtFuerAusbilder blatt={blatt} warnungen={warnungen} />
)}
{/* --- Werkzeuge --- */}
<div className="reihe nicht-drucken">
<a href={`/berichtsheft/${bericht.id}/pdf`} className="knopf knopf-zweit" target="_blank" rel="noreferrer">
Dieses Blatt als PDF
</a>
{eigenes && bericht.status === 'eingereicht' ? (
<form action={blattZurueckziehen}>
<input type="hidden" name="berichtId" value={bericht.id} />
<button type="submit" className="knopf knopf-still">
Einreichung zurückziehen
</button>
</form>
) : null}
</div>
{!eigenes ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="abzeichnen">
<h2 id="abzeichnen" style={{ marginBottom: 0 }}>
Abzeichnen
</h2>
<Abzeichnen berichtId={bericht.id} anmerkung={bericht.anmerkung ?? ''} status={bericht.status} />
</section>
) : null}
{eigenes ? <Formulierungshilfe /> : null}
</article>
);
}
/** Die Nur-Lese-Ansicht für den Ausbilder – dieselben Daten, ohne Formular. */
function AnsichtFuerAusbilder({
blatt,
warnungen,
}: {
blatt: ReturnType<typeof alsWochenblatt>;
warnungen: ReturnType<typeof pruefeWochenblatt>;
}) {
return (
<div className="stapel">
<section className="karte stapel-eng" aria-labelledby="betrieb-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="betrieb-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Betriebliche Tätigkeiten und Unterweisungen
</h2>
<span className="leise">{zahlText(summeBetrieb(blatt))} h</span>
</div>
{blatt.betrieb.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Keine Eintragung.
</p>
) : (
<ul className="leseliste">
{blatt.betrieb.map((eintrag, stelle) => (
<li key={stelle}>
<span>{eintrag.text}</span>
<span className="leise">{eintrag.stunden > 0 ? `${zahlText(eintrag.stunden)} h` : '–'}</span>
</li>
))}
</ul>
)}
</section>
<section className="karte stapel-eng" aria-labelledby="schule-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="schule-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Berufsschule
</h2>
<span className="leise">{zahlText(summeSchule(blatt))} h</span>
</div>
{blatt.schule.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Keine Eintragung.
</p>
) : (
<ul className="leseliste">
{blatt.schule.map((eintrag, stelle) => (
<li key={stelle}>
<span>
{eintrag.lernfeld ? <strong>{eintrag.lernfeld} </strong> : null}
{eintrag.themen}
</span>
<span className="leise">{eintrag.stunden > 0 ? `${zahlText(eintrag.stunden)} h` : '–'}</span>
</li>
))}
</ul>
)}
</section>
{blatt.sonstiges ? (
<section className="karte stapel-eng" aria-labelledby="sonstiges-lesen">
<h2 id="sonstiges-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Sonstiges
</h2>
<p className="notiz-anzeige">{blatt.sonstiges}</p>
</section>
) : null}
<section className="karte stapel-eng" aria-labelledby="pruefung-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="pruefung-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Prüfung
</h2>
<strong>{zahlText(summeStunden(blatt))} Stunden gesamt</strong>
</div>
{warnungen.length === 0 ? (
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
✓ Nichts zu beanstanden.
</p>
) : (
<ul className="warnliste">
{warnungen.map((warnung, stelle) => (
<li key={stelle} data-schwere={warnung.schwere}>
<span className="warnzeichen" aria-hidden="true">
{warnung.schwere === 'warnung' ? '!' : 'i'}
</span>
<span>
{warnung.text}
{warnung.auszug ? <em> &ndash; &bdquo;{warnung.auszug}&ldquo;</em> : null}
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}
/** Stellt zusammen, was die Anwendung über diese Woche weiß. */
function sammleVorschlaege(azubiId: string, von: string, bis: string) {
const alle = inhalte();
const notizen = notizenIm(azubiId, von, bis).map((n) => ({
datum: n.datum,
text: n.text,
stunden: n.stunden,
}));
// Praxisaufgaben, die in dieser Woche abgegeben oder abgenommen wurden.
const staende = aufgabenStaende(azubiId);
const aufgaben = [...staende.values()]
.filter((s) => {
const zeitpunkt = (s.abgenommenAm ?? s.abgegebenAm ?? '').slice(0, 10);
return zeitpunkt >= von && zeitpunkt <= bis;
})
.map((s) => {
const aufgabe = alle.aufgabeNach.get(s.aufgabeId);
return {
id: s.aufgabeId,
titel: aufgabe?.titel ?? s.aufgabeId,
zeitrahmen: aufgabe?.zeitrahmen ?? '',
status: s.status,
};
});
const checklisten = laeufeVon(azubiId)
.filter((l) => l.abgeschlossenAm !== null && l.datum >= von && l.datum <= bis)
.map((l) => ({ id: l.id, titel: l.titel, ticket: l.ticket, kunde: l.kunde }));
// Handouts der Wochen, die in diesem Zeitraum lagen – nur die gelesenen.
const ausbildung = ausbildungNachAzubi(azubiId);
const gelesen = gelesenSet(azubiId);
const wochenImZeitraum = ausbildung
? planVon(ausbildung.id).filter((z) => z.geplantVon <= bis && z.geplantBis >= von)
: [];
const handouts = wochenImZeitraum
.flatMap((z) => alle.wocheNach.get(z.wochenNummer)?.handouts ?? [])
.filter((id) => gelesen.has(id))
.map((id) => ({ id, titel: alle.handoutNach.get(id)?.titel ?? id }));
const lernfelder = [
...new Set(
wochenImZeitraum
.flatMap((z) => alle.wocheNach.get(z.wochenNummer)?.handouts ?? [])
.map((id) => alle.handoutNach.get(id)?.lernfeld)
.filter((lf): lf is string => Boolean(lf)),
),
].map((id) => ({ id, titel: alle.lernfeldNach.get(id)?.titel ?? id }));
const rohdaten: Rohdaten = { tagesnotizen: notizen, aufgaben, checklisten, handouts, lernfelder };
return baueVorschlaege(rohdaten);
}
function zahlText(wert: number): string {
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
}
@@ -0,0 +1,52 @@
import { benutzerNachId } from '@/lib/repos/benutzer';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { alsWochenblatt } from '@/lib/repos/berichtsheft';
import { berichtsheftPdf } from '@/lib/pdf/berichtsheft';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
import { blattZumLesen } from '../../aktionen';
export const dynamic = 'force-dynamic';
/** Ein einzelnes Wochenblatt als PDF. Rechteprüfung über `blattZumLesen`. */
export async function GET(_anfrage: Request, kontext: { params: Promise<{ berichtId: string }> }) {
const { berichtId } = await kontext.params;
const gelesen = await blattZumLesen(berichtId);
if (!gelesen) return new Response('Nicht gefunden.', { status: 404 });
const { bericht } = gelesen;
const azubi = benutzerNachId(bericht.benutzerId);
const ausbildung = ausbildungNachAzubi(bericht.benutzerId);
const ausbilder = ausbildung ? benutzerNachId(ausbildung.ausbilderId) : null;
const abzeichner = bericht.abgezeichnetVon ? benutzerNachId(bericht.abgezeichnetVon) : null;
const pdf = await berichtsheftPdf([
{
nr: bericht.nr,
von: alsDatumText(bericht.von),
bis: alsDatumText(bericht.bis),
azubi: azubi?.name ?? 'unbekannt',
ausbilder: ausbilder?.name ?? '',
beruf: ausbildung?.beruf ?? 'Fachinformatiker/-in für Systemintegration',
ausbildungsjahr: ausbildung ? ausbildungsjahr(ausbildung.beginn, bericht.von) : null,
status: bericht.status,
anmerkung: bericht.anmerkung,
abgezeichnetVon: abzeichner?.name ?? null,
abgezeichnetAm: bericht.abgezeichnetAm ? alsZeitpunktText(bericht.abgezeichnetAm) : null,
blatt: alsWochenblatt(bericht),
},
]);
const dateiname = `Ausbildungsnachweis_${String(bericht.nr).padStart(3, '0')}_${bericht.von}.pdf`;
return new Response(new Uint8Array(pdf), {
headers: {
'Content-Type': 'application/pdf',
'Content-Length': String(pdf.byteLength),
'Content-Disposition': `inline; filename="${dateiname}"`,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
});
}
@@ -0,0 +1,197 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { erfordereBenutzer, erfordereBewertungsrecht, ausbildungVonAzubi } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import {
abzeichnen,
berichtNachId,
einreichen,
inhaltSpeichern,
notizSetzen,
sorgeFuerBlatt,
zurueckgeben,
} from '@/lib/repos/berichtsheft';
import type { Wochenblatt } from '@/lib/berichtsheft/pruefung';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/** Zum Bearbeiten: nur das eigene Blatt, und nur solange es nicht abgezeichnet ist. */
async function blattZumBearbeiten(berichtId: string) {
const benutzer = await erfordereBenutzer();
const bericht = berichtNachId(berichtId);
if (!bericht) throw new Error('Dieses Wochenblatt gibt es nicht.');
if (bericht.benutzerId !== benutzer.id) throw new Error('Kein Zugriff auf dieses Wochenblatt.');
return { benutzer, bericht };
}
/** Zum Lesen: das eigene Blatt oder das eines betreuten Azubis. */
export async function blattZumLesen(berichtId: string) {
const benutzer = await erfordereBenutzer();
const bericht = berichtNachId(berichtId);
if (!bericht) return null;
const bezug = ausbildungVonAzubi(bericht.benutzerId);
if (!darfAzubiDatenSehen(benutzer, bericht.benutzerId, bezug)) return null;
return { benutzer, bericht };
}
function aktualisiere(berichtId?: string) {
revalidatePath('/berichtsheft');
if (berichtId) revalidatePath(`/berichtsheft/${berichtId}`);
revalidatePath('/');
}
// --- Tagesnotizen ----------------------------------------------------------
export async function tagesnotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return { fehler: 'Tagesnotizen schreibt der Azubi.' };
const datum = String(formular.get('datum') ?? '').trim();
if (!datumMuster.test(datum)) return { fehler: 'Ungültiges Datum.' };
const text = String(formular.get('text') ?? '');
if (text.length > 2000) return { fehler: 'Die Notiz ist zu lang – höchstens 2000 Zeichen.' };
const stunden = zahl(formular.get('stunden'));
if (stunden > 24) return { fehler: 'Mehr als 24 Stunden an einem Tag sind nicht plausibel.' };
notizSetzen(benutzer.id, datum, text, stunden);
aktualisiere();
return { erfolg: text.trim() ? 'Notiz gespeichert.' : 'Notiz entfernt.' };
}
// --- Wochenblatt -----------------------------------------------------------
export async function blattAnlegen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return;
const datum = String(formular.get('datum') ?? '').trim();
if (!datumMuster.test(datum)) return;
const bericht = sorgeFuerBlatt(benutzer.id, datum);
aktualisiere(bericht.id);
redirect(`/berichtsheft/${bericht.id}`);
}
/** Liest das Blatt aus dem Formular. Leere Zeilen fliegen raus. */
function leseBlatt(formular: FormData): Wochenblatt {
const betriebTexte = formular.getAll('betriebText').map(String);
const betriebStunden = formular.getAll('betriebStunden');
const schuleLernfelder = formular.getAll('schuleLernfeld').map(String);
const schuleThemen = formular.getAll('schuleThemen').map(String);
const schuleStunden = formular.getAll('schuleStunden');
return {
betrieb: betriebTexte
.map((text, stelle) => ({ text: text.trim(), stunden: zahl(betriebStunden[stelle]) }))
.filter((e) => e.text !== '' || e.stunden > 0),
schule: schuleLernfelder
.map((lernfeld, stelle) => ({
lernfeld: lernfeld.trim(),
themen: (schuleThemen[stelle] ?? '').trim(),
stunden: zahl(schuleStunden[stelle]),
}))
.filter((e) => e.lernfeld !== '' || e.themen !== '' || e.stunden > 0),
sonstiges: String(formular.get('sonstiges') ?? '').trim(),
};
}
export async function blattSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId);
if (bericht.status === 'abgezeichnet') {
return { fehler: 'Das Blatt ist abgezeichnet und damit gesperrt.' };
}
const blatt = leseBlatt(formular);
if (blatt.betrieb.length > 30) return { fehler: 'Mehr als 30 Tätigkeiten in einer Woche sind zu viel.' };
if (blatt.sonstiges.length > 2000) return { fehler: 'Der Text unter „Sonstiges" ist zu lang.' };
inhaltSpeichern(berichtId, blatt);
aktualisiere(berichtId);
return { erfolg: 'Gespeichert.' };
}
/**
* Einreichen.
*
* Die Prüfung ist eine **Warnung, keine Sperre** – deshalb wird hier nichts
* geprüft, was das Einreichen verhindern könnte. Der Azubi hat die Warnungen
* auf der Seite gesehen; ob er sie beherzigt, entscheidet er.
*/
export async function blattEinreichen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId);
if (bericht.status === 'abgezeichnet') return { fehler: 'Das Blatt ist schon abgezeichnet.' };
if (bericht.status === 'eingereicht') return { fehler: 'Das Blatt liegt schon beim Ausbilder.' };
// Vor dem Einreichen den letzten Stand übernehmen, damit nichts verlorengeht.
inhaltSpeichern(berichtId, leseBlatt(formular));
einreichen(berichtId);
aktualisiere(berichtId);
return { erfolg: 'Eingereicht. Dein Ausbilder sieht das Blatt jetzt unter „Was auf dich wartet".' };
}
export async function blattZurueckziehen(formular: FormData): Promise<void> {
const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId);
if (bericht.status !== 'eingereicht') return;
zurueckgeben(berichtId, bericht.anmerkung ?? '');
aktualisiere(berichtId);
}
// --- Abzeichnen durch den Ausbilder ----------------------------------------
export async function blattAbzeichnen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer();
const berichtId = String(formular.get('berichtId') ?? '');
const bericht = berichtNachId(berichtId);
if (!bericht) return { fehler: 'Dieses Wochenblatt gibt es nicht.' };
erfordereBewertungsrecht(ausbilder, bericht.benutzerId);
if (bericht.status === 'abgezeichnet') return { fehler: 'Das Blatt ist schon abgezeichnet.' };
const anmerkung = String(formular.get('anmerkung') ?? '').trim();
if (anmerkung.length > 2000) return { fehler: 'Die Anmerkung ist zu lang.' };
abzeichnen(berichtId, ausbilder.id, anmerkung || null);
aktualisiere(berichtId);
return { erfolg: 'Abgezeichnet. Das Blatt ist damit gesperrt.' };
}
export async function blattZurueckgeben(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer();
const berichtId = String(formular.get('berichtId') ?? '');
const bericht = berichtNachId(berichtId);
if (!bericht) return { fehler: 'Dieses Wochenblatt gibt es nicht.' };
erfordereBewertungsrecht(ausbilder, bericht.benutzerId);
const anmerkung = String(formular.get('anmerkung') ?? '').trim();
if (!anmerkung) {
return { fehler: 'Ohne Anmerkung weiß der Azubi nicht, was er ändern soll. Schreib bitte einen Satz dazu.' };
}
if (anmerkung.length > 2000) return { fehler: 'Die Anmerkung ist zu lang.' };
zurueckgeben(berichtId, anmerkung);
aktualisiere(berichtId);
return { erfolg: 'Zurückgegeben. Der Azubi kann das Blatt jetzt wieder bearbeiten.' };
}
function zahl(wert: FormDataEntryValue | null | undefined): number {
const roh = String(wert ?? '').replace(',', '.').trim();
const gelesen = Number(roh);
return Number.isFinite(gelesen) && gelesen > 0 ? Math.round(gelesen * 100) / 100 : 0;
}
+256
View File
@@ -0,0 +1,256 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { alsWochenblatt, berichteVon, fehlendeWochen, notizenIm } from '@/lib/repos/berichtsheft';
import { summeStunden } from '@/lib/berichtsheft/pruefung';
import { alsDatumText, heute, kalenderwocheText, montagDerWoche, werktage, wochentagText } from '@/lib/zeit';
import { Tagesnotizen, type Tagesfeld } from './Tagesnotizen';
import { Formulierungshilfe } from './Formulierungshilfe';
import { blattAnlegen } from './aktionen';
import { BerichtsMarke } from './BerichtsMarke';
export const metadata: Metadata = { title: 'Berichtsheft' };
export const dynamic = 'force-dynamic';
export default async function BerichtsheftSeite() {
const benutzer = await erfordereBenutzer();
return benutzer.rolle === 'ausbilder' ? (
<AusbilderSicht ausbilderId={benutzer.id} />
) : (
<AzubiSicht azubiId={benutzer.id} />
);
}
// --- Azubi -----------------------------------------------------------------
function AzubiSicht({ azubiId }: { azubiId: string }) {
const ausbildung = ausbildungNachAzubi(azubiId);
const berichte = berichteVon(azubiId);
const heuteDatum = heute();
const tage = werktage(heuteDatum);
const notizen = notizenIm(azubiId, tage[0]!, tage[4]!);
const notizNach = new Map(notizen.map((n) => [n.datum, n]));
const felder: Tagesfeld[] = tage.map((datum) => ({
datum,
wochentag: wochentagText(datum),
datumText: alsDatumText(datum),
text: notizNach.get(datum)?.text ?? '',
stunden: notizNach.get(datum)?.stunden ?? 0,
istHeute: datum === heuteDatum,
}));
const dieseWoche = berichte.find((b) => b.von === montagDerWoche(heuteDatum));
const fehlend = ausbildung ? fehlendeWochen(azubiId, ausbildung.beginn, heuteDatum) : [];
const abgezeichnet = berichte.filter((b) => b.status === 'abgezeichnet').length;
return (
<div className="stapel">
<div>
<h1>Berichtsheft</h1>
<p className="leise">
{berichte.length} {berichte.length === 1 ? 'Wochenblatt' : 'Wochenblätter'} · {abgezeichnet} abgezeichnet
{fehlend.length > 0 ? ` · ${fehlend.length} Wochen ohne Blatt` : ''}
</p>
</div>
{fehlend.length > 0 ? (
<div className="hinweis hinweis-warnung">
<strong>
Für {fehlend.length} vergangene {fehlend.length === 1 ? 'Woche' : 'Wochen'} gibt es noch kein Blatt.
</strong>{' '}
Ohne geführten Nachweis gibt es keine Zulassung zur Abschlussprüfung – das ist der Teil, der in der
Praxis am häufigsten schiefgeht.
<div className="reihe" style={{ marginTop: '0.6rem' }}>
{fehlend.slice(0, 6).map((montag) => (
<form key={montag} action={blattAnlegen}>
<input type="hidden" name="datum" value={montag} />
<button type="submit" className="knopf knopf-zweit">
{kalenderwocheText(montag)} nachtragen
</button>
</form>
))}
{fehlend.length > 6 ? <span className="leise">… und {fehlend.length - 6} weitere</span> : null}
</div>
</div>
) : null}
<section className="karte-grund stapel" aria-labelledby="notizen">
<div>
<h2 id="notizen" style={{ marginBottom: '0.15rem' }}>
Diese Woche in Stichworten
</h2>
<p className="leise" style={{ margin: 0 }}>
Zwei Minuten am Tag. Freitags baust du daraus mit einem Klick das Wochenblatt – das ist deutlich
weniger Arbeit, als sich am Freitag an den Dienstag zu erinnern.
</p>
</div>
<Tagesnotizen tage={felder} />
</section>
<section className="stapel" aria-labelledby="blaetter">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="blaetter" style={{ margin: 0 }}>
Wochenblätter
</h2>
{dieseWoche ? (
<Link href={`/berichtsheft/${dieseWoche.id}`} className="knopf">
Blatt dieser Woche öffnen
</Link>
) : (
<form action={blattAnlegen}>
<input type="hidden" name="datum" value={heuteDatum} />
<button type="submit" className="knopf knopf-akzent">
Blatt für diese Woche anlegen
</button>
</form>
)}
</div>
{berichte.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch kein Wochenblatt. Leg das erste an, sobald die Woche läuft.
</p>
) : (
<ul className="blattliste">
{berichte.map((bericht) => {
const blatt = alsWochenblatt(bericht);
return (
<li key={bericht.id}>
<Link href={`/berichtsheft/${bericht.id}`} className="blattzeile-link">
<span className="blatt-nr">Nr. {bericht.nr}</span>
<span className="blatt-zeitraum">
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
<span className="leise" style={{ display: 'block' }}>
{kalenderwocheText(bericht.von)} · {blatt.betrieb.length} Tätigkeiten ·{' '}
{zahlText(summeStunden(blatt))} h
</span>
</span>
<BerichtsMarke status={bericht.status} />
</Link>
</li>
);
})}
</ul>
)}
</section>
{berichte.length > 0 ? (
<section className="karte stapel-eng nicht-drucken" aria-labelledby="export">
<h2 id="export" style={{ marginBottom: 0, fontSize: '1rem' }}>
Ganzen Zeitraum ausgeben
</h2>
<p className="leise" style={{ margin: 0 }}>
Alle Blätter als ein PDF, ein Blatt je Seite – zum Ausdrucken und Abheften.
</p>
<div>
<a
href={`/berichtsheft/pdf?von=${berichte.at(-1)!.von}&bis=${berichte[0]!.bis}`}
className="knopf knopf-zweit"
target="_blank"
rel="noreferrer"
>
Alle {berichte.length} Blätter als PDF
</a>
</div>
</section>
) : null}
<Formulierungshilfe />
</div>
);
}
// --- Ausbilder -------------------------------------------------------------
function AusbilderSicht({ ausbilderId }: { ausbilderId: string }) {
const meine = ausbildungenVonAusbilder(ausbilderId);
return (
<div className="stapel">
<div>
<h1>Berichtshefte</h1>
<p className="leise">Eingereichte Blätter abzeichnen oder zur Nacharbeit zurückgeben.</p>
</div>
{meine.length === 0 ? (
<p className="hinweis hinweis-warnung">
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
meine.map(({ azubi, ausbildung }) => {
const berichte = berichteVon(azubi.id);
const offene = berichte.filter((b) => b.status === 'eingereicht');
const fehlend = fehlendeWochen(azubi.id, ausbildung.beginn);
return (
<section key={azubi.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
{azubi.name}
</h2>
<div className="reihe">
{offene.length > 0 ? (
<span className="marke-status" data-ton="warnung">
● {offene.length} zum Abzeichnen
</span>
) : null}
{fehlend.length > 0 ? (
<span className="marke-status" data-ton="fehler">
! {fehlend.length} Wochen ohne Blatt
</span>
) : null}
{berichte.length > 0 ? (
<a
href={`/berichtsheft/pdf?azubi=${azubi.id}&von=${berichte.at(-1)!.von}&bis=${berichte[0]!.bis}`}
className="knopf knopf-zweit"
target="_blank"
rel="noreferrer"
>
Alles als PDF
</a>
) : null}
</div>
</div>
{berichte.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch kein Wochenblatt.
</p>
) : (
<ul className="blattliste">
{berichte.map((bericht) => {
const blatt = alsWochenblatt(bericht);
return (
<li key={bericht.id}>
<Link href={`/berichtsheft/${bericht.id}`} className="blattzeile-link">
<span className="blatt-nr">Nr. {bericht.nr}</span>
<span className="blatt-zeitraum">
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
<span className="leise" style={{ display: 'block' }}>
{blatt.betrieb.length} Tätigkeiten · {zahlText(summeStunden(blatt))} h
</span>
</span>
<BerichtsMarke status={bericht.status} />
</Link>
</li>
);
})}
</ul>
)}
</section>
);
})
)}
</div>
);
}
function zahlText(wert: number): string {
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
}
@@ -0,0 +1,77 @@
import { erfordereBenutzer, ausbildungVonAzubi } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { alsWochenblatt, berichteVon } from '@/lib/repos/berichtsheft';
import { berichtsheftPdf, type Blattangaben } from '@/lib/pdf/berichtsheft';
import { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
export const dynamic = 'force-dynamic';
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/**
* Ein ganzer Zeitraum als PDF, ein Blatt je Seite.
*
* /berichtsheft/pdf?von=2026-08-01&bis=2026-12-31[&azubi=…]
*
* Ohne `azubi` das eigene Berichtsheft. Der Ausbilder gibt die Kennung an –
* geprüft wird sie serverseitig gegen das Ausbildungsverhältnis.
*/
export async function GET(anfrage: Request) {
const benutzer = await erfordereBenutzer();
const adresse = new URL(anfrage.url);
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (adresse.searchParams.get('azubi') ?? '');
if (!azubiId) return new Response('Nicht gefunden.', { status: 404 });
const bezug = ausbildungVonAzubi(azubiId);
if (!darfAzubiDatenSehen(benutzer, azubiId, bezug)) {
return new Response('Nicht gefunden.', { status: 404 });
}
const von = adresse.searchParams.get('von') ?? '';
const bis = adresse.searchParams.get('bis') ?? '';
if (!datumMuster.test(von) || !datumMuster.test(bis) || von > bis) {
return new Response('Ungültiger Zeitraum.', { status: 400 });
}
const azubi = benutzerNachId(azubiId);
const ausbilder = bezug ? benutzerNachId(bezug.ausbilderId) : null;
// Aufsteigend, damit das Heft in der richtigen Reihenfolge ausgedruckt wird.
const berichte = berichteVon(azubiId)
.filter((b) => b.bis >= von && b.von <= bis)
.sort((a, b) => a.nr - b.nr);
const blaetter: Blattangaben[] = berichte.map((bericht) => {
const abzeichner = bericht.abgezeichnetVon ? benutzerNachId(bericht.abgezeichnetVon) : null;
return {
nr: bericht.nr,
von: alsDatumText(bericht.von),
bis: alsDatumText(bericht.bis),
azubi: azubi?.name ?? 'unbekannt',
ausbilder: ausbilder?.name ?? '',
beruf: bezug?.beruf ?? 'Fachinformatiker/-in für Systemintegration',
ausbildungsjahr: bezug ? ausbildungsjahr(bezug.beginn, bericht.von) : null,
status: bericht.status,
anmerkung: bericht.anmerkung,
abgezeichnetVon: abzeichner?.name ?? null,
abgezeichnetAm: bericht.abgezeichnetAm ? alsZeitpunktText(bericht.abgezeichnetAm) : null,
blatt: alsWochenblatt(bericht),
};
});
const pdf = await berichtsheftPdf(blaetter, `${alsDatumText(von)} bis ${alsDatumText(bis)}`);
const dateiname = `Ausbildungsnachweis_${von}_bis_${bis}.pdf`;
return new Response(new Uint8Array(pdf), {
headers: {
'Content-Type': 'application/pdf',
'Content-Length': String(pdf.byteLength),
'Content-Disposition': `inline; filename="${dateiname}"`,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
});
}
@@ -0,0 +1,174 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import {
durchlaufAbschliessen,
durchlaufStarten,
kopfdatenSpeichern,
schrittBemerkung,
type Rueckmeldung,
} from './aktionen';
export function StartFormular({
checklisteId,
mitTicket,
heuteDatum,
}: {
checklisteId: string;
mitTicket: boolean;
heuteDatum: string;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(durchlaufStarten, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="checklisteId" value={checklisteId} />
<div className="reihe" style={{ gap: '0.4rem', alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 130px' }}>
<label htmlFor={`datum-${checklisteId}`}>Datum</label>
<input id={`datum-${checklisteId}`} name="datum" type="date" defaultValue={heuteDatum} required />
</div>
{mitTicket ? (
<div style={{ flex: '1 1 110px' }}>
<label htmlFor={`ticket-${checklisteId}`}>Ticket</label>
<input id={`ticket-${checklisteId}`} name="ticket" type="text" maxLength={80} placeholder="z. B. 4711" />
</div>
) : null}
</div>
<div className="feld" style={{ marginTop: 0 }}>
<label htmlFor={`kunde-${checklisteId}`}>Kunde oder Bereich</label>
<input
id={`kunde-${checklisteId}`}
name="kunde"
type="text"
maxLength={120}
placeholder="z. B. Buchhaltung, Halle 2"
/>
</div>
<div>
<Absenden className="knopf knopf-akzent">Durchlauf starten</Absenden>
</div>
</form>
);
}
export function KopfdatenFormular({
laufId,
datum,
ticket,
kunde,
bemerkung,
gesperrt,
}: {
laufId: string;
datum: string;
ticket: string;
kunde: string;
bemerkung: string;
gesperrt: boolean;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(kopfdatenSpeichern, {});
if (gesperrt) return null;
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="laufId" value={laufId} />
<div className="reihe" style={{ alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 140px' }}>
<label htmlFor="lauf-datum">Datum</label>
<input id="lauf-datum" name="datum" type="date" defaultValue={datum} required />
</div>
<div style={{ flex: '1 1 120px' }}>
<label htmlFor="lauf-ticket">Ticket</label>
<input id="lauf-ticket" name="ticket" type="text" maxLength={80} defaultValue={ticket} />
</div>
<div style={{ flex: '2 1 200px' }}>
<label htmlFor="lauf-kunde">Kunde oder Bereich</label>
<input id="lauf-kunde" name="kunde" type="text" maxLength={120} defaultValue={kunde} />
</div>
</div>
<div className="feld" style={{ marginTop: 0 }}>
<label htmlFor="lauf-bemerkung">Bemerkung zum ganzen Durchlauf</label>
<textarea
id="lauf-bemerkung"
name="bemerkung"
rows={3}
maxLength={2000}
defaultValue={bemerkung}
placeholder="Besonderheiten, Abweichungen, was beim nächsten Mal anders laufen sollte."
/>
</div>
<div>
<Absenden className="knopf knopf-zweit">Angaben speichern</Absenden>
</div>
</form>
);
}
export function BemerkungFormular({
laufId,
schrittId,
bemerkung,
gesperrt,
}: {
laufId: string;
schrittId: string;
bemerkung: string;
gesperrt: boolean;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(schrittBemerkung, {});
if (gesperrt) {
return bemerkung ? <p className="schritt-bemerkung">{bemerkung}</p> : null;
}
return (
<details className="schritt-notiz" open={Boolean(bemerkung)}>
<summary>{bemerkung ? 'Bemerkung' : 'Bemerkung hinzufügen'}</summary>
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="schrittId" value={schrittId} />
<label htmlFor={`bem-${schrittId}`} className="nur-lesehilfe">
Bemerkung zu diesem Schritt
</label>
<textarea
id={`bem-${schrittId}`}
name="bemerkung"
rows={2}
maxLength={1000}
defaultValue={bemerkung}
placeholder="z. B. Netzteil auffällig, Ersatz bestellt"
/>
<div>
<Absenden className="knopf knopf-still">Speichern</Absenden>
</div>
</form>
</details>
);
}
export function AbschlussFormular({ laufId, offen }: { laufId: string; offen: number }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(durchlaufAbschliessen, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="laufId" value={laufId} />
<Absenden className="knopf knopf-akzent">
{offen === 0 ? 'Durchlauf abschließen' : `Abschließen (noch ${offen} offen)`}
</Absenden>
</form>
);
}
@@ -0,0 +1,166 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { ausbildungVonAzubi } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import {
bemerkungSetzen,
laufAbschliessen,
laufBemerkung,
laufKopfdaten,
laufLoeschen,
laufNachId,
laufStarten,
laufWiederOeffnen,
schrittSetzen,
standVon,
} from '@/lib/repos/checklisten';
import { darfAbschliessen } from '@/lib/checklisten/fortschritt';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/**
* Holt einen Durchlauf und prüft, ob der Anmelder ihn **bearbeiten** darf.
*
* Bearbeiten darf nur, wem der Durchlauf gehört. Der Ausbilder darf mitlesen
* (siehe `laufZumLesen`), aber nicht für seinen Azubi abhaken – sonst stünde
* im Nachweis eine Arbeit, die der Azubi nie gemacht hat.
*/
async function laufZumBearbeiten(laufId: string) {
const benutzer = await erfordereBenutzer();
const lauf = laufNachId(laufId);
if (!lauf) throw new Error('Diesen Durchlauf gibt es nicht.');
if (lauf.benutzerId !== benutzer.id) throw new Error('Kein Zugriff auf diesen Durchlauf.');
return { benutzer, lauf };
}
/** Zum Lesen: der eigene Durchlauf oder der eines betreuten Azubis. */
export async function laufZumLesen(laufId: string) {
const benutzer = await erfordereBenutzer();
const lauf = laufNachId(laufId);
if (!lauf) return null;
const bezug = ausbildungVonAzubi(lauf.benutzerId);
if (!darfAzubiDatenSehen(benutzer, lauf.benutzerId, bezug)) return null;
return { benutzer, lauf };
}
function aktualisiere(laufId: string) {
revalidatePath('/checklisten');
revalidatePath(`/checklisten/lauf/${laufId}`);
revalidatePath('/');
}
export async function durchlaufStarten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') {
return { fehler: 'Durchläufe führt der Azubi – der Ausbilder liest mit.' };
}
const checklisteId = String(formular.get('checklisteId') ?? '');
const vorlage = inhalte().checklisteNach.get(checklisteId);
if (!vorlage) return { fehler: 'Diese Checkliste gibt es nicht.' };
const datum = String(formular.get('datum') ?? '').trim();
if (!datumMuster.test(datum)) return { fehler: 'Bitte ein gültiges Datum wählen.' };
const ticket = String(formular.get('ticket') ?? '');
const kunde = String(formular.get('kunde') ?? '');
if (ticket.length > 80) return { fehler: 'Die Ticketangabe ist zu lang.' };
if (kunde.length > 120) return { fehler: 'Die Kundenangabe ist zu lang.' };
const lauf = laufStarten({ benutzerId: benutzer.id, vorlage, datum, ticket, kunde });
revalidatePath('/checklisten');
redirect(`/checklisten/lauf/${lauf.id}`);
}
export async function schrittUmschalten(formular: FormData): Promise<void> {
const laufId = String(formular.get('laufId') ?? '');
const schrittId = String(formular.get('schrittId') ?? '');
const erledigt = String(formular.get('erledigt') ?? '') === '1';
const { lauf } = await laufZumBearbeiten(laufId);
if (lauf.abgeschlossenAm) return; // abgeschlossene Durchläufe sind Nachweis, nicht Arbeitsblatt
schrittSetzen(laufId, schrittId, erledigt);
aktualisiere(laufId);
}
export async function schrittBemerkung(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const laufId = String(formular.get('laufId') ?? '');
const schrittId = String(formular.get('schrittId') ?? '');
const bemerkung = String(formular.get('bemerkung') ?? '');
const { lauf } = await laufZumBearbeiten(laufId);
if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist abgeschlossen.' };
if (bemerkung.length > 1000) return { fehler: 'Die Bemerkung ist zu lang – höchstens 1000 Zeichen.' };
bemerkungSetzen(laufId, schrittId, bemerkung);
aktualisiere(laufId);
return { erfolg: bemerkung.trim() ? 'Bemerkung gespeichert.' : 'Bemerkung entfernt.' };
}
export async function kopfdatenSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const laufId = String(formular.get('laufId') ?? '');
const { lauf } = await laufZumBearbeiten(laufId);
if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist abgeschlossen.' };
const datum = String(formular.get('datum') ?? '').trim();
if (!datumMuster.test(datum)) return { fehler: 'Bitte ein gültiges Datum wählen.' };
const bemerkung = String(formular.get('bemerkung') ?? '');
if (bemerkung.length > 2000) return { fehler: 'Die Bemerkung ist zu lang – höchstens 2000 Zeichen.' };
laufKopfdaten(laufId, {
datum,
ticket: String(formular.get('ticket') ?? ''),
kunde: String(formular.get('kunde') ?? ''),
});
laufBemerkung(laufId, bemerkung);
aktualisiere(laufId);
return { erfolg: 'Angaben gespeichert.' };
}
export async function durchlaufAbschliessen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const laufId = String(formular.get('laufId') ?? '');
const { lauf } = await laufZumBearbeiten(laufId);
if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist schon abgeschlossen.' };
const stand = standVon(laufId);
if (!darfAbschliessen(stand)) {
const offen = stand.gesamt - stand.erledigt;
return {
fehler: `Es ${offen === 1 ? 'ist noch ein Schritt' : `sind noch ${offen} Schritte`} offen. Ein Durchlauf wird erst abgeschlossen, wenn alles abgehakt ist.`,
};
}
laufAbschliessen(laufId);
aktualisiere(laufId);
return { erfolg: 'Durchlauf abgeschlossen. Er bleibt als Nachweis erhalten.' };
}
export async function durchlaufWiederOeffnen(formular: FormData): Promise<void> {
const laufId = String(formular.get('laufId') ?? '');
await laufZumBearbeiten(laufId);
laufWiederOeffnen(laufId);
aktualisiere(laufId);
}
export async function durchlaufVerwerfen(formular: FormData): Promise<void> {
const laufId = String(formular.get('laufId') ?? '');
const { lauf } = await laufZumBearbeiten(laufId);
// Ein abgeschlossener Durchlauf ist ein Nachweis und wird nicht gelöscht.
if (lauf.abgeschlossenAm) return;
laufLoeschen(laufId);
revalidatePath('/checklisten');
redirect('/checklisten');
}
@@ -0,0 +1,233 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { inhalte } from '@/lib/inhalte';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { standVon } from '@/lib/repos/checklisten';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { AbschlussFormular, BemerkungFormular, KopfdatenFormular } from '../../Formulare';
import { durchlaufVerwerfen, durchlaufWiederOeffnen, laufZumLesen, schrittUmschalten } from '../../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ laufId: string }> }): Promise<Metadata> {
const { laufId } = await params;
const gelesen = await laufZumLesen(laufId);
return { title: gelesen ? gelesen.lauf.titel : 'Durchlauf' };
}
export default async function LaufSeite({ params }: { params: Promise<{ laufId: string }> }) {
const { laufId } = await params;
// Enthält die Rechteprüfung: Wer nicht darf, bekommt null – und damit 404.
const gelesen = await laufZumLesen(laufId);
if (!gelesen) notFound();
const { benutzer, lauf } = gelesen;
const eigener = lauf.benutzerId === benutzer.id;
const gesperrt = Boolean(lauf.abgeschlossenAm) || !eigener;
const alle = inhalte();
const vorlage = alle.checklisteNach.get(lauf.checklisteId);
const stand = standVon(laufId);
const bearbeiter = eigener ? benutzer : benutzerNachId(lauf.benutzerId);
const positionen = (vorlage?.rahmenplan ?? [])
.map((p) => alle.positionNach.get(p))
.filter((p) => p !== undefined);
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/checklisten">Checklisten</Link>
{!eigener && bearbeiter ? ` › ${bearbeiter.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
{lauf.abgeschlossenAm ? (
<span className="marke-status" data-ton="gut">
✓ abgeschlossen am {alsZeitpunktText(lauf.abgeschlossenAm)}
</span>
) : (
<span className="marke-status" data-ton="warnung">
◐ in Arbeit
</span>
)}
<span className="leise">
{alsDatumText(lauf.datum)}
{lauf.ticket ? ` · Ticket ${lauf.ticket}` : ''}
{lauf.kunde ? ` · ${lauf.kunde}` : ''}
</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{lauf.titel}</h1>
{vorlage ? <p className="handout-worum">{vorlage.worum}</p> : null}
{bearbeiter && !eigener ? <p className="leise">Bearbeitet von {bearbeiter.name}</p> : null}
</header>
{!vorlage ? (
<p className="hinweis hinweis-warnung">
Die Vorlage <code>{lauf.checklisteId}</code> gibt es im Repository nicht mehr. Der Durchlauf bleibt
vollständig lesbar – Titel und Schritttexte wurden beim Start mitkopiert.
</p>
) : null}
{positionen.length > 0 ? (
<div className="handout-zuordnung karte">
<dl>
<div>
<dt>Ausbildungsrahmenplan</dt>
<dd>
{positionen.map((p) => (
<span key={p.id} className="zuordnung-marke" title={`${p.titel} (${p.verordnung})`}>
<strong>{p.id}</strong> {p.kurz}
</span>
))}
</dd>
</div>
</dl>
</div>
) : null}
<section className="karte stapel-eng" aria-labelledby="gesamtstand">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="gesamtstand" style={{ margin: 0, fontSize: '1rem' }}>
Fortschritt
</h2>
<span className="leise">
{stand.erledigt} von {stand.gesamt} Schritten · {stand.prozent} %
</span>
</div>
<div
className="balken"
role="progressbar"
aria-valuenow={stand.erledigt}
aria-valuemin={0}
aria-valuemax={stand.gesamt}
aria-label={`${stand.erledigt} von ${stand.gesamt} Schritten erledigt`}
>
<span style={{ width: `${stand.prozent}%` }} />
</div>
<ul className="gruppenstand">
{stand.gruppen.map((gruppe) => (
<li key={gruppe.gruppeId}>
<span>{gruppe.titel}</span>
<span className="balken" style={{ width: '70px' }}>
<span style={{ width: `${gruppe.prozent}%` }} />
</span>
<span className="leise">
{gruppe.erledigt}/{gruppe.gesamt}
</span>
</li>
))}
</ul>
</section>
{stand.gruppen.map((gruppe) => (
<section key={gruppe.gruppeId} className="stapel-eng" aria-labelledby={`g-${gruppe.gruppeId}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id={`g-${gruppe.gruppeId}`} style={{ margin: 0 }}>
{gruppe.titel}
</h2>
<span className={`marke-status${gruppe.vollstaendig ? '' : ''}`} data-ton={gruppe.vollstaendig ? 'gut' : 'neutral'}>
{gruppe.vollstaendig ? '✓' : '○'} {gruppe.erledigt} von {gruppe.gesamt}
</span>
</div>
<ul className="schrittliste">
{gruppe.schritte.map((schritt) => (
<li key={schritt.schrittId} data-erledigt={schritt.erledigt ? 'ja' : 'nein'}>
<form action={schrittUmschalten} className="schritt-haken">
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="schrittId" value={schritt.schrittId} />
<input type="hidden" name="erledigt" value={schritt.erledigt ? '0' : '1'} />
<button
type="submit"
className="hakenknopf"
disabled={gesperrt}
aria-pressed={schritt.erledigt}
title={gesperrt ? 'Gesperrt' : schritt.erledigt ? 'Haken entfernen' : 'Als erledigt abhaken'}
>
<span aria-hidden="true">{schritt.erledigt ? '✓' : ''}</span>
<span className="nur-lesehilfe">
{schritt.text} – {schritt.erledigt ? 'erledigt' : 'offen'}
</span>
</button>
</form>
<div className="schritt-inhalt">
<span className="schritt-text">{schritt.text}</span>
<BemerkungFormular
laufId={laufId}
schrittId={schritt.schrittId}
bemerkung={schritt.bemerkung ?? ''}
gesperrt={gesperrt}
/>
</div>
</li>
))}
</ul>
</section>
))}
{eigener && !lauf.abgeschlossenAm ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="angaben">
<h2 id="angaben" style={{ marginBottom: 0 }}>
Angaben zum Durchlauf
</h2>
<KopfdatenFormular
laufId={laufId}
datum={lauf.datum}
ticket={lauf.ticket ?? ''}
kunde={lauf.kunde ?? ''}
bemerkung={lauf.bemerkung ?? ''}
gesperrt={false}
/>
</section>
) : lauf.bemerkung ? (
<section className="karte stapel-eng" aria-labelledby="bemerkung">
<h2 id="bemerkung" style={{ marginBottom: 0, fontSize: '1rem' }}>
Bemerkung zum Durchlauf
</h2>
<p className="notiz-anzeige">{lauf.bemerkung}</p>
</section>
) : null}
<div className="reihe nicht-drucken">
<a href={`/checklisten/lauf/${laufId}/pdf`} className="knopf knopf-zweit" target="_blank" rel="noreferrer">
Als PDF öffnen
</a>
{eigener && !lauf.abgeschlossenAm ? (
<AbschlussFormular laufId={laufId} offen={stand.gesamt - stand.erledigt} />
) : null}
{eigener && lauf.abgeschlossenAm ? (
<form action={durchlaufWiederOeffnen}>
<input type="hidden" name="laufId" value={laufId} />
<button type="submit" className="knopf knopf-still">
Wieder öffnen
</button>
</form>
) : null}
{eigener && !lauf.abgeschlossenAm && stand.erledigt === 0 ? (
<form action={durchlaufVerwerfen}>
<input type="hidden" name="laufId" value={laufId} />
<button type="submit" className="knopf knopf-still">
Verwerfen
</button>
</form>
) : null}
</div>
{lauf.abgeschlossenAm ? (
<p className="leise" style={{ marginBottom: 0 }}>
Abgeschlossene Durchläufe bleiben als Nachweis erhalten und lassen sich nicht löschen.
</p>
) : null}
</article>
);
}
@@ -0,0 +1,55 @@
import { inhalte } from '@/lib/inhalte';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { standVon } from '@/lib/repos/checklisten';
import { checklistenPdf } from '@/lib/pdf/checkliste';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { laufZumLesen } from '../../../aktionen';
export const dynamic = 'force-dynamic';
/**
* Der Durchlauf als PDF.
*
* Die Rechteprüfung steckt in `laufZumLesen` – dieselbe Funktion, die auch die
* Seite benutzt. Wer nicht darf, bekommt 404 statt 403: Ob es den Durchlauf
* gibt, geht einen Unbefugten nichts an.
*/
export async function GET(_anfrage: Request, kontext: { params: Promise<{ laufId: string }> }) {
const { laufId } = await kontext.params;
const gelesen = await laufZumLesen(laufId);
if (!gelesen) return new Response('Nicht gefunden.', { status: 404 });
const { lauf } = gelesen;
const vorlage = inhalte().checklisteNach.get(lauf.checklisteId);
const bearbeiter = benutzerNachId(lauf.benutzerId);
const stand = standVon(laufId);
const pdf = await checklistenPdf(
{
titel: lauf.titel,
checklisteId: lauf.checklisteId,
bearbeiter: bearbeiter?.name ?? 'unbekannt',
datum: alsDatumText(lauf.datum),
ticket: lauf.ticket,
kunde: lauf.kunde,
bemerkung: lauf.bemerkung,
gestartetAm: alsZeitpunktText(lauf.gestartetAm),
abgeschlossenAm: lauf.abgeschlossenAm ? alsZeitpunktText(lauf.abgeschlossenAm) : null,
worum: vorlage?.worum,
},
stand,
);
const dateiname = `Checkliste_${lauf.checklisteId}_${lauf.datum}.pdf`.replace(/[^\w.\-]/g, '_');
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',
},
});
}
+159
View File
@@ -0,0 +1,159 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import { laeufeVon, standVon } from '@/lib/repos/checklisten';
import { alsDatumText, alsZeitpunktText, heute } from '@/lib/zeit';
import { StartFormular } from './Formulare';
export const metadata: Metadata = { title: 'Checklisten' };
export const dynamic = 'force-dynamic';
export default async function ChecklistenSeite() {
const benutzer = await erfordereBenutzer();
const alle = inhalte();
const istAzubi = benutzer.rolle === 'azubi';
// Der Ausbilder sieht die Durchläufe seiner Azubis, der Azubi seine eigenen.
const beteiligte = istAzubi
? [{ id: benutzer.id, name: benutzer.name }]
: ausbildungenVonAusbilder(benutzer.id).map(({ azubi }) => ({ id: azubi.id, name: azubi.name }));
return (
<div className="stapel">
<div>
<h1>Checklisten</h1>
<p className="leise">
{alle.checklisten.length} Vorlagen für wiederkehrende Arbeiten. Aus einer Vorlage wird ein Durchlauf –
und der bleibt als Nachweis erhalten.
</p>
</div>
<section className="stapel" aria-labelledby="vorlagen">
<h2 id="vorlagen">Vorlagen</h2>
<div className="raster">
{alle.checklisten.map((vorlage) => {
const schritte = vorlage.gruppen.reduce((summe, g) => summe + g.schritte.length, 0);
const block = alle.blockNach.get(vorlage.block);
return (
<section key={vorlage.id} className="karte stapel-eng">
<div>
<h3 style={{ fontSize: '1rem', marginBottom: '0.15rem' }}>{vorlage.titel}</h3>
<p className="leise" style={{ margin: 0 }}>
{schritte} Schritte in {vorlage.gruppen.length} Gruppen
{block ? ` · ${block.titel}` : ''}
</p>
</div>
<p style={{ margin: 0, fontSize: '0.93rem' }}>{vorlage.worum}</p>
{istAzubi ? (
<StartFormular checklisteId={vorlage.id} mitTicket={vorlage.mit_ticket} heuteDatum={heute()} />
) : (
<p className="leise" style={{ margin: 0 }}>
Quelle: <code>content/checklisten/{vorlage.id}.yaml</code>
</p>
)}
</section>
);
})}
</div>
</section>
{beteiligte.map((person) => {
const laeufe = laeufeVon(person.id);
const offene = laeufe.filter((l) => !l.abgeschlossenAm);
const fertige = laeufe.filter((l) => l.abgeschlossenAm);
return (
<section key={person.id} className="stapel" aria-labelledby={`laeufe-${person.id}`}>
<h2 id={`laeufe-${person.id}`}>
{istAzubi ? 'Deine Durchläufe' : `Durchläufe von ${person.name}`}
</h2>
{laeufe.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch kein Durchlauf gestartet.
</p>
) : (
<>
{offene.length > 0 ? (
<div className="stapel-eng">
<h3 style={{ fontSize: '0.95rem', marginBottom: 0 }}>Offen</h3>
{offene.map((lauf) => (
<Laufzeile key={lauf.id} laufId={lauf.id} titel={lauf.titel} datum={lauf.datum}
ticket={lauf.ticket} kunde={lauf.kunde} abgeschlossenAm={null} />
))}
</div>
) : null}
{fertige.length > 0 ? (
<div className="stapel-eng">
<h3 style={{ fontSize: '0.95rem', marginBottom: 0 }}>
Abgeschlossen ({fertige.length})
</h3>
{fertige.map((lauf) => (
<Laufzeile key={lauf.id} laufId={lauf.id} titel={lauf.titel} datum={lauf.datum}
ticket={lauf.ticket} kunde={lauf.kunde} abgeschlossenAm={lauf.abgeschlossenAm} />
))}
</div>
) : null}
</>
)}
</section>
);
})}
</div>
);
}
function Laufzeile({
laufId,
titel,
datum,
ticket,
kunde,
abgeschlossenAm,
}: {
laufId: string;
titel: string;
datum: string;
ticket: string | null;
kunde: string | null;
abgeschlossenAm: string | null;
}) {
const stand = standVon(laufId);
return (
<div className="laufzeile">
<Link href={`/checklisten/lauf/${laufId}`} className="laufzeile-haupt">
<span className="laufzeile-titel">{titel}</span>
<span className="leise">
{alsDatumText(datum)}
{ticket ? ` · Ticket ${ticket}` : ''}
{kunde ? ` · ${kunde}` : ''}
</span>
</Link>
<div className="laufzeile-stand">
<span className="leise">
{stand.erledigt} / {stand.gesamt}
</span>
<span className="balken" style={{ width: '90px' }}>
<span style={{ width: `${stand.prozent}%` }} />
</span>
{abgeschlossenAm ? (
<span className="marke-status" data-ton="gut" title={alsZeitpunktText(abgeschlossenAm)}>
✓ fertig
</span>
) : (
<span className="marke-status" data-ton="warnung">
◐ offen
</span>
)}
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
'use client';
import Link from 'next/link';
import { useEffect } from 'react';
/**
* Auffangnetz für den angemeldeten Bereich.
*
* Verweigerte Berechtigungen landen **nicht** hier, sondern auf der Seite
* „Nicht gefunden" – siehe erfordereSichtAufAzubi. Hier bleibt der echte
* Störfall übrig. Der Fehlertext selbst wird im Produktionsbetrieb von Next.js
* geschwärzt und steht nur im Serverprotokoll; deshalb gibt es hier keine
* Fallunterscheidung nach Meldung, sondern nur das Kennzeichen zur Fehlersuche.
*/
export default function Fehlerseite({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error('[lernbar] Fehler in der Oberfläche:', error);
}, [error]);
return (
<div className="karte-grund stapel" style={{ maxWidth: '38rem', margin: '2rem auto', textAlign: 'center' }}>
<p style={{ fontSize: '2.2rem', margin: 0 }} aria-hidden="true">
⚠
</p>
<h1 style={{ marginBottom: '0.2rem' }}>Da ist etwas schiefgegangen</h1>
<p className="leise">
Der Vorgang ließ sich nicht abschließen. Der Fehler steht im Protokoll des Servers.
</p>
<div className="reihe" style={{ justifyContent: 'center' }}>
<Link href="/" className="knopf">
Zur Startseite
</Link>
<button type="button" className="knopf knopf-zweit" onClick={reset}>
Noch einmal versuchen
</button>
</div>
{error.digest ? (
<p className="leise" style={{ marginBottom: 0 }}>
Kennzeichen für die Fehlersuche: <code>{error.digest}</code>
</p>
) : null}
</div>
);
}
@@ -0,0 +1,282 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { gespraechBestaetigen, gespraechSpeichern, type Rueckmeldung } from './aktionen';
import { liegtWeitAuseinander, type Gespraechsinhalt } from '@/lib/feedback/inhalt';
/**
* Der Bogen für das Gespräch.
*
* Jede Seite trägt ihre eigene Spalte ein: der Ausbilder seine Einschätzung
* und die Freitexte, der Azubi seine Selbsteinschätzung. Wo beide deutlich
* auseinanderliegen, wird das hervorgehoben – genau darüber lohnt das
* Gespräch.
*/
export function Gespraechsformular({
gespraechId,
inhalt,
leitfaden,
istAzubi,
gesperrt,
datum,
bereitsBestaetigt,
}: {
gespraechId: string;
inhalt: Gespraechsinhalt;
leitfaden: { abschnitt: string; fragen: string[] }[];
istAzubi: boolean;
gesperrt: boolean;
datum: string;
bereitsBestaetigt: boolean;
}) {
const [speicherstand, speichern] = useActionState<Rueckmeldung, FormData>(gespraechSpeichern, {});
const [bestaetigungsstand, bestaetigen] = useActionState<Rueckmeldung, FormData>(gespraechBestaetigen, {});
const eigeneSpalte = istAzubi ? 'azubi' : 'ausbilder';
return (
<form action={speichern} className="stapel">
<input type="hidden" name="gespraechId" value={gespraechId} />
{/* Nach Abschluss steht das Datum schon groß im Kopf – ein zweites,
gesperrtes Feld daneben sagt nichts dazu. */}
{!istAzubi && !gesperrt ? (
<div className="feld" style={{ maxWidth: '14rem' }}>
<label htmlFor="datum">Datum des Gesprächs</label>
<input id="datum" name="datum" type="date" defaultValue={datum} />
</div>
) : null}
{/* --- Bewertungsbogen --- */}
<section className="stapel-eng" aria-labelledby="bogen">
<div>
<h2 id="bogen" style={{ marginBottom: '0.15rem' }}>
Bewertungsbogen
</h2>
<p className="leise" style={{ margin: 0 }}>
{inhalt.skala[0]?.wert} = {inhalt.skala[0]?.text} bis {inhalt.skala.at(-1)?.wert} ={' '}
{inhalt.skala.at(-1)?.text}.{' '}
{istAzubi
? 'Du trägst deine Selbsteinschätzung ein – die Spalte des Ausbilders ist für dich gesperrt.'
: 'Du trägst deine Einschätzung ein; die Selbsteinschätzung kommt vom Azubi.'}
</p>
</div>
<ul className="kriterienbogen">
{inhalt.kriterien.map((kriterium) => {
const bewertung = inhalt.bewertungen.find((b) => b.kriteriumId === kriterium.id);
const eigener = eigeneSpalte === 'azubi' ? bewertung?.azubi : bewertung?.ausbilder;
const anderer = eigeneSpalte === 'azubi' ? bewertung?.ausbilder : bewertung?.azubi;
const weitAuseinander = liegtWeitAuseinander(bewertung);
return (
<li key={kriterium.id} data-abweichung={weitAuseinander ? 'ja' : 'nein'}>
<div className="kriterium-kopf">
<strong>{kriterium.titel}</strong>
<span className="leise">{kriterium.erlaeuterung}</span>
</div>
<fieldset className="stufenwahl" disabled={gesperrt}>
<legend className="nur-lesehilfe">
{istAzubi ? 'Deine Selbsteinschätzung' : 'Deine Einschätzung'} für {kriterium.titel}
</legend>
{inhalt.skala.map((stufe) => (
<label key={stufe.wert} className="stufe" title={stufe.text}>
<input
type="radio"
name={`stufe-${kriterium.id}`}
value={stufe.wert}
defaultChecked={eigener === stufe.wert}
/>
<span>{stufe.wert}</span>
</label>
))}
</fieldset>
<div className="kriterium-fuss">
<span className="leise">
{anderer === null || anderer === undefined
? `${istAzubi ? 'Ausbilder' : 'Azubi'}: noch offen`
: `${istAzubi ? 'Ausbilder' : 'Selbsteinschätzung'}: ${anderer} (${
inhalt.skala.find((s) => s.wert === anderer)?.text ?? ''
})`}
</span>
{weitAuseinander ? (
<span className="marke-status" data-ton="warnung">
! unterschiedliche Sicht
</span>
) : null}
</div>
{/* Wer nicht schreiben darf oder kann, sieht die Bemerkung als
Text – und ein leeres Feld gar nicht. Ein abgeschlossenes
Blatt für die Akte soll nicht voller Platzhalter stehen. */}
{istAzubi || gesperrt ? (
bewertung?.bemerkung ? (
<p className="schritt-bemerkung">{bewertung.bemerkung}</p>
) : null
) : (
<input
type="text"
name={`bemerkung-${kriterium.id}`}
maxLength={500}
defaultValue={bewertung?.bemerkung ?? ''}
placeholder="Bemerkung – ein Beispiel sagt mehr als eine Zahl"
/>
)}
</li>
);
})}
</ul>
</section>
{/* --- Freitexte --- */}
{/* Nach Abschluss liest auch der Ausbilder nur noch – ein gesperrtes
Eingabefeld sieht aus, als könnte man es doch noch benutzen. */}
{istAzubi || gesperrt ? (
<section className="stapel-eng" aria-labelledby="texte">
<h2 id="texte">Stärken, Entwicklungsfelder, Ziele</h2>
<Lesefeld titel="Stärken" text={inhalt.staerken} />
<Lesefeld titel="Entwicklungsfelder" text={inhalt.entwicklungsfelder} />
<Lesefeld titel="Vereinbarte Ziele" text={inhalt.ziele} />
{inhalt.sonstiges ? <Lesefeld titel="Sonstiges" text={inhalt.sonstiges} /> : null}
</section>
) : (
<section className="stapel-eng" aria-labelledby="texte">
<h2 id="texte">Stärken, Entwicklungsfelder, Ziele</h2>
<div className="feld">
<label htmlFor="staerken">Stärken</label>
<textarea
id="staerken"
name="staerken"
rows={3}
maxLength={4000}
defaultValue={inhalt.staerken}
placeholder="Woran hast du gemerkt, dass er besser geworden ist? Nenne ein Beispiel."
/>
</div>
<div className="feld">
<label htmlFor="entwicklungsfelder">Entwicklungsfelder</label>
<textarea
id="entwicklungsfelder"
name="entwicklungsfelder"
rows={3}
maxLength={4000}
defaultValue={inhalt.entwicklungsfelder}
placeholder="Was soll besser werden – und woran würde man es merken?"
/>
</div>
<div className="feld">
<label htmlFor="ziele">Vereinbarte Ziele bis zum nächsten Gespräch</label>
<textarea
id="ziele"
name="ziele"
rows={3}
maxLength={4000}
defaultValue={inhalt.ziele}
placeholder="So formuliert, dass beim nächsten Mal feststellbar ist, ob sie erreicht wurden."
/>
</div>
<div className="feld">
<label htmlFor="sonstiges">Sonstiges</label>
<textarea
id="sonstiges"
name="sonstiges"
rows={2}
maxLength={4000}
defaultValue={inhalt.sonstiges}
/>
</div>
</section>
)}
{/* --- Leitfaden --- */}
<details className="karte formulierungshilfe" open={!istAzubi && !gesperrt}>
<summary>
<strong>Gesprächsleitfaden</strong>
<span className="leise"> – Fragen für das Gespräch</span>
</summary>
<div className="stapel" style={{ marginTop: '0.9rem' }}>
{leitfaden.map((teil, stelle) => (
<section key={teil.abschnitt} className="stapel-eng">
<h3 style={{ fontSize: '0.95rem', marginBottom: '0.2rem' }}>{teil.abschnitt}</h3>
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{teil.fragen.map((frage) => (
<li key={frage}>{frage}</li>
))}
</ul>
{istAzubi || gesperrt ? (
inhalt.leitfadennotizen[stelle]?.notiz ? (
<p className="notiz-anzeige">{inhalt.leitfadennotizen[stelle]!.notiz}</p>
) : null
) : (
<>
<label htmlFor={`leitfaden-${stelle}`} className="nur-lesehilfe">
Notiz zu {teil.abschnitt}
</label>
<textarea
id={`leitfaden-${stelle}`}
name={`leitfaden-${stelle}`}
rows={2}
maxLength={4000}
defaultValue={inhalt.leitfadennotizen[stelle]?.notiz ?? ''}
placeholder="Notiz zum Gespräch"
/>
</>
)}
</section>
))}
</div>
</details>
<Meldung zustand={speicherstand} />
<Meldung zustand={bestaetigungsstand} />
{!gesperrt ? (
<div className="reihe nicht-drucken">
<Absenden className="knopf knopf-zweit">Speichern</Absenden>
{!bereitsBestaetigt ? (
<button type="submit" className="knopf knopf-akzent" formAction={bestaetigen}>
{istAzubi ? 'Gespräch bestätigen' : 'Als Ausbilder bestätigen'}
</button>
) : null}
</div>
) : null}
{!gesperrt && !bereitsBestaetigt ? (
<p className="leise" style={{ marginBottom: 0 }}>
Bestätigen heißt: Du bist mit dem Inhalt einverstanden. Sobald beide bestätigt haben, ist das
Gespräch unveränderlich und gehört in die Ausbildungsakte.
</p>
) : null}
</form>
);
}
function Lesefeld({ titel, text }: { titel: string; text: string }) {
return (
<div>
<span className="wochentag-marke">{titel}</span>
{text.trim() ? (
<p className="notiz-anzeige">{text}</p>
) : (
<p className="leise" style={{ margin: 0 }}>
Noch nichts eingetragen.
</p>
)}
</div>
);
}
@@ -0,0 +1,145 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { inhalte } from '@/lib/inhalte';
import { inhaltVon } from '@/lib/repos/feedback';
import { istAbgeschlossen, zusammenfassung } from '@/lib/feedback/inhalt';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { Gespraechsformular } from '../Gespraechsformular';
import { bestaetigungZurueck, gespraechVerwerfen, gespraechZumLesen } from '../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ gespraechId: string }> }): Promise<Metadata> {
const { gespraechId } = await params;
const gelesen = await gespraechZumLesen(gespraechId);
return { title: gelesen ? `Feedbackgespräch ${alsDatumText(gelesen.gespraech.datum)}` : 'Feedbackgespräch' };
}
export default async function GespraechsSeite({ params }: { params: Promise<{ gespraechId: string }> }) {
const { gespraechId } = await params;
const gelesen = await gespraechZumLesen(gespraechId);
if (!gelesen) notFound();
const { benutzer, gespraech, ausbildung } = gelesen;
const vorlage = inhalte().feedback;
const inhalt = inhaltVon(gespraech, vorlage);
const summe = zusammenfassung(inhalt);
const istAzubi = benutzer.rolle === 'azubi';
const azubi = benutzerNachId(ausbildung.azubiId);
const ausbilder = benutzerNachId(ausbildung.ausbilderId);
if (!azubi) notFound();
const fertig = istAbgeschlossen({
bestaetigtAzubi: gespraech.bestaetigtAzubi === 1,
bestaetigtAusbilder: gespraech.bestaetigtAusbilder === 1,
});
const bereitsBestaetigt = istAzubi ? gespraech.bestaetigtAzubi === 1 : gespraech.bestaetigtAusbilder === 1;
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/feedback">Feedbackgespräche</Link>
{!istAzubi ? ` › ${azubi.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="gespraech-datum gespraech-datum-gross">{alsDatumText(gespraech.datum)}</span>
{fertig ? (
<span className="marke-status" data-ton="gut">
🔒 abgeschlossen und unveränderlich
</span>
) : (
<span className="marke-status" data-ton="warnung">
◐ in Arbeit
</span>
)}
</div>
<h1 style={{ marginBottom: '0.2rem' }}>Feedbackgespräch</h1>
<p className="leise">
{azubi.name}
{ausbilder ? ` und ${ausbilder.name}` : ''} · {ausbildung.beruf}
</p>
</header>
{/* --- Bestätigungsstand --- */}
<section className="karte stapel-eng" aria-labelledby="bestaetigung">
<h2 id="bestaetigung" style={{ margin: 0, fontSize: '1rem' }}>
Bestätigung
</h2>
<ul className="offen-liste">
<li data-erledigt={gespraech.bestaetigtAusbilder === 1 ? 'ja' : 'nein'}>
<span>{ausbilder?.name ?? 'Ausbilder'}</span>
<span className="leise">
{gespraech.bestaetigtAusbilder === 1
? `bestätigt ${alsZeitpunktText(gespraech.bestaetigtAusbilderAm)}`
: 'noch offen'}
</span>
</li>
<li data-erledigt={gespraech.bestaetigtAzubi === 1 ? 'ja' : 'nein'}>
<span>{azubi.name}</span>
<span className="leise">
{gespraech.bestaetigtAzubi === 1
? `bestätigt ${alsZeitpunktText(gespraech.bestaetigtAzubiAm)}`
: 'noch offen'}
</span>
</li>
</ul>
{summe.abweichungen.length > 0 ? (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
<strong>Unterschiedliche Sicht bei:</strong> {summe.abweichungen.join(', ')}. Genau darüber lohnt
das Gespräch.
</p>
) : null}
{bereitsBestaetigt && !fertig ? (
<form action={bestaetigungZurueck} className="nicht-drucken">
<input type="hidden" name="gespraechId" value={gespraech.id} />
<button type="submit" className="knopf knopf-still">
Bestätigung zurücknehmen
</button>
</form>
) : null}
</section>
<Gespraechsformular
gespraechId={gespraech.id}
inhalt={inhalt}
leitfaden={vorlage.leitfaden}
istAzubi={istAzubi}
gesperrt={fertig}
datum={gespraech.datum}
bereitsBestaetigt={bereitsBestaetigt}
/>
<div className="reihe nicht-drucken">
<a href={`/feedback/${gespraech.id}/pdf`} className="knopf knopf-zweit" target="_blank" rel="noreferrer">
Als PDF für die Akte
</a>
{!istAzubi && gespraech.bestaetigtAzubi === 0 && gespraech.bestaetigtAusbilder === 0 ? (
<form action={gespraechVerwerfen}>
<input type="hidden" name="gespraechId" value={gespraech.id} />
<button type="submit" className="knopf knopf-still">
Verwerfen
</button>
</form>
) : null}
</div>
{vorlage.hinweis ? (
<p className="leise" style={{ marginBottom: 0 }}>
{vorlage.hinweis}
</p>
) : null}
</article>
);
}
@@ -0,0 +1,44 @@
import { benutzerNachId } from '@/lib/repos/benutzer';
import { inhalte } from '@/lib/inhalte';
import { inhaltVon } from '@/lib/repos/feedback';
import { feedbackPdf } from '@/lib/pdf/feedback';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { gespraechZumLesen } from '../../aktionen';
export const dynamic = 'force-dynamic';
/** Das Feedbackgespräch als PDF. Rechteprüfung über `gespraechZumLesen`. */
export async function GET(_anfrage: Request, kontext: { params: Promise<{ gespraechId: string }> }) {
const { gespraechId } = await kontext.params;
const gelesen = await gespraechZumLesen(gespraechId);
if (!gelesen) return new Response('Nicht gefunden.', { status: 404 });
const { gespraech, ausbildung } = gelesen;
const azubi = benutzerNachId(ausbildung.azubiId);
const ausbilder = benutzerNachId(ausbildung.ausbilderId);
const pdf = await feedbackPdf({
datum: alsDatumText(gespraech.datum),
azubi: azubi?.name ?? 'unbekannt',
ausbilder: ausbilder?.name ?? '',
beruf: ausbildung.beruf,
inhalt: inhaltVon(gespraech, inhalte().feedback),
bestaetigtAzubiAm: gespraech.bestaetigtAzubiAm ? alsZeitpunktText(gespraech.bestaetigtAzubiAm) : null,
bestaetigtAusbilderAm: gespraech.bestaetigtAusbilderAm
? alsZeitpunktText(gespraech.bestaetigtAusbilderAm)
: null,
});
const dateiname = `Feedbackgespraech_${gespraech.datum}.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',
},
});
}
+180
View File
@@ -0,0 +1,180 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { erfordereBenutzer, erfordereBewertungsrecht } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import {
anlegen,
bestaetigen,
bestaetigungZuruecknehmen,
gespraechNachId,
inhaltSpeichern,
inhaltVon,
loeschen,
} from '@/lib/repos/feedback';
import { istAbgeschlossen, type Gespraechsinhalt } from '@/lib/feedback/inhalt';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/**
* Holt ein Gespräch samt Rechteprüfung.
*
* Beide Seiten dürfen lesen und – solange nicht beide bestätigt haben –
* schreiben. Das Gespräch gehört ihnen gemeinsam; eine Selbsteinschätzung,
* die der Ausbilder eintragen könnte, wäre keine.
*/
export async function gespraechZumLesen(id: string) {
const benutzer = await erfordereBenutzer();
const gespraech = gespraechNachId(id);
if (!gespraech) return null;
const ausbildung = ausbildungNachId(gespraech.ausbildungId);
if (!ausbildung) return null;
if (!darfAzubiDatenSehen(benutzer, ausbildung.azubiId, ausbildung)) return null;
return { benutzer, gespraech, ausbildung };
}
function aktualisiere(id?: string) {
revalidatePath('/feedback');
if (id) revalidatePath(`/feedback/${id}`);
}
export async function gespraechAnlegen(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
const ausbildung = ausbildungNachAzubi(azubiId);
if (!ausbildung) return;
const datum = String(formular.get('datum') ?? '').trim();
const gespraech = anlegen(
ausbildung.id,
ausbilder.id,
inhalte().feedback,
datumMuster.test(datum) ? datum : undefined,
);
aktualisiere(gespraech.id);
redirect(`/feedback/${gespraech.id}`);
}
export async function gespraechSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id);
if (!gelesen) return { fehler: 'Dieses Gespräch gibt es nicht.' };
const { benutzer, gespraech } = gelesen;
if (istAbgeschlossen({
bestaetigtAzubi: gespraech.bestaetigtAzubi === 1,
bestaetigtAusbilder: gespraech.bestaetigtAusbilder === 1,
})) {
return { fehler: 'Das Gespräch ist von beiden Seiten bestätigt und damit unveränderlich.' };
}
const vorlage = inhalte().feedback;
const bestand = inhaltVon(gespraech, vorlage);
const istAzubi = benutzer.rolle === 'azubi';
// Jede Seite trägt nur ihre eigene Spalte ein.
const bewertungen: Gespraechsinhalt['bewertungen'] = bestand.bewertungen.map((bewertung) => {
const roh = String(formular.get(`stufe-${bewertung.kriteriumId}`) ?? '').trim();
const gelesen = roh === '' ? null : Number(roh);
const gueltig = gelesen !== null && bestand.skala.some((s) => s.wert === gelesen) ? gelesen : null;
return {
...bewertung,
ausbilder: istAzubi ? bewertung.ausbilder : gueltig,
azubi: istAzubi ? gueltig : bewertung.azubi,
// Die Bemerkung je Kriterium schreibt der Ausbilder.
bemerkung: istAzubi
? bewertung.bemerkung
: String(formular.get(`bemerkung-${bewertung.kriteriumId}`) ?? '').slice(0, 500),
};
});
// Die Freitexte schreibt der Ausbilder; der Azubi liest sie.
const neu: Gespraechsinhalt = istAzubi
? { ...bestand, bewertungen }
: {
...bestand,
bewertungen,
leitfadennotizen: bestand.leitfadennotizen.map((notiz, stelle) => ({
...notiz,
notiz: String(formular.get(`leitfaden-${stelle}`) ?? '').slice(0, 4000),
})),
staerken: String(formular.get('staerken') ?? '').slice(0, 4000),
entwicklungsfelder: String(formular.get('entwicklungsfelder') ?? '').slice(0, 4000),
ziele: String(formular.get('ziele') ?? '').slice(0, 4000),
sonstiges: String(formular.get('sonstiges') ?? '').slice(0, 4000),
};
const datum = String(formular.get('datum') ?? '').trim();
inhaltSpeichern(id, neu, !istAzubi && datumMuster.test(datum) ? datum : undefined);
aktualisiere(id);
return { erfolg: istAzubi ? 'Deine Selbsteinschätzung ist gespeichert.' : 'Gespeichert.' };
}
export async function gespraechBestaetigen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id);
if (!gelesen) return { fehler: 'Dieses Gespräch gibt es nicht.' };
const { benutzer, gespraech } = gelesen;
const rolle = benutzer.rolle === 'azubi' ? 'azubi' : 'ausbilder';
const schon = rolle === 'azubi' ? gespraech.bestaetigtAzubi === 1 : gespraech.bestaetigtAusbilder === 1;
if (schon) return { fehler: 'Du hast bereits bestätigt.' };
bestaetigen(id, rolle);
aktualisiere(id);
const andereHatSchon = rolle === 'azubi' ? gespraech.bestaetigtAusbilder === 1 : gespraech.bestaetigtAzubi === 1;
return {
erfolg: andereHatSchon
? 'Bestätigt. Beide Seiten haben zugestimmt – das Gespräch ist jetzt unveränderlich.'
: 'Bestätigt. Sobald die andere Seite ebenfalls bestätigt, ist das Gespräch unveränderlich.',
};
}
export async function bestaetigungZurueck(formular: FormData): Promise<void> {
const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id);
if (!gelesen) return;
const { benutzer, gespraech } = gelesen;
const rolle = benutzer.rolle === 'azubi' ? 'azubi' : 'ausbilder';
// Hat die andere Seite schon bestätigt, ist das Gespräch abgeschlossen.
const andereHatSchon = rolle === 'azubi' ? gespraech.bestaetigtAusbilder === 1 : gespraech.bestaetigtAzubi === 1;
if (andereHatSchon) return;
bestaetigungZuruecknehmen(id, rolle);
aktualisiere(id);
}
export async function gespraechVerwerfen(formular: FormData): Promise<void> {
const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id);
if (!gelesen) return;
const { benutzer, gespraech } = gelesen;
if (benutzer.rolle !== 'ausbilder') return;
// Bestätigtes bleibt. Verworfen wird nur, was noch niemand unterschrieben hat.
if (gespraech.bestaetigtAzubi === 1 || gespraech.bestaetigtAusbilder === 1) return;
loeschen(id);
revalidatePath('/feedback');
redirect('/feedback');
}
+152
View File
@@ -0,0 +1,152 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import { gespraecheVon, inhaltVon } from '@/lib/repos/feedback';
import { istAbgeschlossen, zusammenfassung } from '@/lib/feedback/inhalt';
import { alsDatumText, heute, montagDerWoche } from '@/lib/zeit';
import { gespraechAnlegen } from './aktionen';
export const metadata: Metadata = { title: 'Feedbackgespräche' };
export const dynamic = 'force-dynamic';
export default async function FeedbackSeite() {
const benutzer = await erfordereBenutzer();
const vorlage = inhalte().feedback;
const beteiligte =
benutzer.rolle === 'ausbilder'
? ausbildungenVonAusbilder(benutzer.id).map(({ azubi, ausbildung }) => ({ azubi, ausbildung }))
: (() => {
const ausbildung = ausbildungNachAzubi(benutzer.id);
return ausbildung ? [{ azubi: benutzer, ausbildung }] : [];
})();
return (
<div className="stapel">
<div>
<h1>Feedbackgespräche</h1>
<p className="leise">
Monatlich, mit Leitfaden und Bewertungsbogen. Von beiden Seiten bestätigt und danach unveränderlich –
für die Ausbildungsakte.
</p>
</div>
{beteiligte.length === 0 ? (
<p className="hinweis hinweis-warnung">
{benutzer.rolle === 'ausbilder' ? (
<>
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</>
) : (
'Zu deinem Konto ist noch keine Ausbildung hinterlegt.'
)}
</p>
) : (
beteiligte.map(({ azubi, ausbildung }) => {
const gespraeche = gespraecheVon(ausbildung.id);
const letztes = gespraeche[0];
const faellig =
!letztes || montagDerWoche(letztes.datum) < montagDerWoche(vorMonaten(heute(), 1));
return (
<section key={ausbildung.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
{benutzer.rolle === 'ausbilder' ? azubi.name : 'Deine Gespräche'}
</h2>
{benutzer.rolle === 'ausbilder' ? (
<form action={gespraechAnlegen}>
<input type="hidden" name="azubiId" value={azubi.id} />
<input type="hidden" name="datum" value={heute()} />
<button type="submit" className={`knopf ${faellig ? 'knopf-akzent' : 'knopf-zweit'}`}>
Neues Gespräch anlegen
</button>
</form>
) : null}
</div>
{faellig && benutzer.rolle === 'ausbilder' ? (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
{letztes
? `Das letzte Gespräch war am ${alsDatumText(letztes.datum)} – das ist über einen Monat her.`
: 'Es gab noch kein Feedbackgespräch.'}
</p>
) : null}
{gespraeche.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch kein Gespräch geführt.
</p>
) : (
<ul className="gespraechsliste">
{gespraeche.map((gespraech) => {
const inhalt = inhaltVon(gespraech, vorlage);
const summe = zusammenfassung(inhalt);
const fertig = istAbgeschlossen({
bestaetigtAzubi: gespraech.bestaetigtAzubi === 1,
bestaetigtAusbilder: gespraech.bestaetigtAusbilder === 1,
});
return (
<li key={gespraech.id}>
<Link href={`/feedback/${gespraech.id}`} className="gespraechszeile">
<span className="gespraech-datum">{alsDatumText(gespraech.datum)}</span>
<span className="gespraech-inhalt">
{summe.bewertet} von {summe.gesamt} Kriterien bewertet
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{summe.schnittAusbilder !== null
? `Ausbilder ⌀ ${zahl(summe.schnittAusbilder)}`
: 'noch keine Einschätzung'}
{summe.schnittAzubi !== null ? ` · Selbsteinschätzung ⌀ ${zahl(summe.schnittAzubi)}` : ''}
{summe.abweichungen.length > 0
? ` · ${summe.abweichungen.length} unterschiedliche Sicht${summe.abweichungen.length === 1 ? '' : 'en'}`
: ''}
</span>
</span>
<span className="reihe" style={{ gap: '0.35rem' }}>
<span
className="marke-status"
data-ton={gespraech.bestaetigtAusbilder === 1 ? 'gut' : 'neutral'}
>
{gespraech.bestaetigtAusbilder === 1 ? '✓' : '○'} Ausbilder
</span>
<span
className="marke-status"
data-ton={gespraech.bestaetigtAzubi === 1 ? 'gut' : 'neutral'}
>
{gespraech.bestaetigtAzubi === 1 ? '✓' : '○'} Azubi
</span>
{fertig ? (
<span className="marke-status" data-ton="gut">
🔒 abgeschlossen
</span>
) : null}
</span>
</Link>
</li>
);
})}
</ul>
)}
</section>
);
})
)}
</div>
);
}
function vorMonaten(datum: string, monate: number): string {
const d = new Date(`${datum}T12:00:00Z`);
d.setUTCMonth(d.getUTCMonth() - monate);
return d.toISOString().slice(0, 10);
}
function zahl(wert: number): string {
return wert.toString().replace('.', ',');
}
+5
View File
@@ -5,6 +5,7 @@ import { inhalte } from '@/lib/inhalte';
import { glossarAnker } from '@/lib/inhalte/glossarverweise';
import { normalisiere } from '@/lib/inhalte/laden';
import { GlossarSuche } from './GlossarSuche';
import { Druckknopf } from '@/komponenten/Druckknopf';
export const metadata: Metadata = { title: 'Glossar' };
export const dynamic = 'force-dynamic';
@@ -67,6 +68,10 @@ export default async function GlossarSeite({ searchParams }: { searchParams: Pro
))}
</dl>
)}
<div className="reihe nicht-drucken">
<Druckknopf beschriftung="Glossar drucken" />
</div>
</div>
);
}
@@ -7,6 +7,7 @@ import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise';
import { gelesenAm } from '@/lib/repos/fortschritt';
import { alsZeitpunktText } from '@/lib/zeit';
import { gelesenUmschalten } from './aktionen';
import { Druckknopf } from '@/komponenten/Druckknopf';
export const dynamic = 'force-dynamic';
@@ -159,6 +160,10 @@ export default async function HandoutSeite({ params }: { params: Promise<{ id: s
</form>
) : null}
<div className="reihe nicht-drucken">
<Druckknopf beschriftung="Handout drucken" />
</div>
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Weitere Handouts">
{vorher ? (
<Link href={`/handouts/${vorher.id}`} className="knopf knopf-still">
+56
View File
@@ -0,0 +1,56 @@
'use server';
import { revalidatePath } from 'next/cache';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { bewerte } from '@/lib/repos/karten';
import { bewertungen, fachNamen, type Bewertung } from '@/lib/lernen/sm2';
import { alsDatumText, heute } from '@/lib/zeit';
export type Bewertungsergebnis = {
fehler?: string;
/** Wann die Karte wieder drankommt, in Worten. */
wiederAm?: string;
fach?: number;
fachName?: string;
/** Kommt die Karte noch in dieser Sitzung wieder? */
nochHeute?: boolean;
};
/**
* Eine Selbstbewertung entgegennehmen.
*
* Nur der Azubi lernt – der Ausbilder sieht die Karten, bewertet sie aber nicht
* für ihn. Sonst stünde im Lernstand etwas, das der Azubi nie gekonnt hat.
*/
export async function karteBewerten(karteId: string, bewertung: string): Promise<Bewertungsergebnis> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') {
return { fehler: 'Nur der Azubi führt seinen eigenen Lernstand.' };
}
if (!inhalte().karteNach.has(karteId)) {
return { fehler: 'Diese Karte gibt es nicht.' };
}
if (!bewertungen.includes(bewertung as Bewertung)) {
return { fehler: 'Unbekannte Bewertung.' };
}
const heuteDatum = heute();
const ergebnis = bewerte(benutzer.id, karteId, bewertung as Bewertung, heuteDatum);
revalidatePath('/karten');
revalidatePath('/');
return {
wiederAm:
ergebnis.faelligAm <= heuteDatum
? 'heute noch einmal'
: ergebnis.stand.abstandTage === 1
? 'morgen'
: `in ${ergebnis.stand.abstandTage} Tagen (${alsDatumText(ergebnis.faelligAm)})`,
fach: ergebnis.fach,
fachName: fachNamen[ergebnis.fach],
nochHeute: ergebnis.faelligAm <= heuteDatum,
};
}
@@ -0,0 +1,279 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { karteBewerten, type Bewertungsergebnis } from '../aktionen';
import { einreihen } from '@/lib/offline/warteschlange';
export type Lernkarte = {
id: string;
frage: string;
antwort: string;
handoutId: string;
handoutTitel: string;
blockTitel: string;
fach: number;
fachName: string;
};
type Bewertung = 'sitzt' | 'unsicher' | 'nochmal';
const knoepfe: { bewertung: Bewertung; text: string; taste: string; klasse: string; erklaerung: string }[] = [
{ bewertung: 'nochmal', text: 'Nochmal', taste: '1', klasse: 'bewerten-nochmal', erklaerung: 'kommt heute wieder' },
{ bewertung: 'unsicher', text: 'Unsicher', taste: '2', klasse: 'bewerten-unsicher', erklaerung: 'kommt morgen' },
{ bewertung: 'sitzt', text: 'Sitzt', taste: '3', klasse: 'bewerten-sitzt', erklaerung: 'kommt später' },
];
/**
* Der Lernmodus.
*
* Gebaut für das Handy: große Flächen, Antippen zum Umdrehen, Wischen als
* Abkürzung, kein Überfahren mit der Maus. Am Rechner tun es zusätzlich die
* Tasten 1, 2, 3 und die Leertaste.
*/
export function Lernmodus({
karten,
block,
benutzerId,
}: {
karten: Lernkarte[];
block: string | null;
benutzerId: string;
}) {
const [stapel, setzeStapel] = useState<Lernkarte[]>(karten);
const [stelle, setzeStelle] = useState(0);
const [umgedreht, setzeUmgedreht] = useState(false);
const [laeuft, setzeLaeuft] = useState(false);
const [rueckmeldung, setzeRueckmeldung] = useState<Bewertungsergebnis | null>(null);
const [erledigt, setzeErledigt] = useState(0);
const [zaehler, setzeZaehler] = useState<Record<Bewertung, number>>({ sitzt: 0, unsicher: 0, nochmal: 0 });
const beruehrungStart = useRef<{ x: number; y: number } | null>(null);
const karte = stapel[stelle];
const fertig = !karte;
const bewerten = useCallback(
async (bewertung: Bewertung) => {
if (!karte || laeuft) return;
setzeLaeuft(true);
const ergebnis = await bewertungAbsetzen(benutzerId, karte.id, bewertung);
setzeRueckmeldung(ergebnis);
setzeZaehler((z) => ({ ...z, [bewertung]: z[bewertung] + 1 }));
setzeErledigt((e) => e + 1);
// „Nochmal" heißt: heute wieder. Also hinten anhängen, statt sie
// erst am nächsten Tag zu zeigen – sonst ist die Bewertung folgenlos.
if (bewertung === 'nochmal') {
setzeStapel((s) => [...s, karte]);
}
setzeUmgedreht(false);
setzeStelle((s) => s + 1);
setzeLaeuft(false);
},
[karte, laeuft, benutzerId],
);
useEffect(() => {
function beiTaste(ereignis: KeyboardEvent) {
if (ereignis.target instanceof HTMLElement && ['INPUT', 'TEXTAREA'].includes(ereignis.target.tagName)) return;
if (ereignis.key === ' ' || ereignis.key === 'Enter') {
ereignis.preventDefault();
if (!umgedreht) setzeUmgedreht(true);
return;
}
if (!umgedreht) return;
const treffer = knoepfe.find((k) => k.taste === ereignis.key);
if (treffer) {
ereignis.preventDefault();
void bewerten(treffer.bewertung);
}
}
window.addEventListener('keydown', beiTaste);
return () => window.removeEventListener('keydown', beiTaste);
}, [umgedreht, bewerten]);
function beiBeruehrungStart(ereignis: React.TouchEvent) {
const punkt = ereignis.touches[0];
if (punkt) beruehrungStart.current = { x: punkt.clientX, y: punkt.clientY };
}
function beiBeruehrungEnde(ereignis: React.TouchEvent) {
const start = beruehrungStart.current;
const punkt = ereignis.changedTouches[0];
beruehrungStart.current = null;
if (!start || !punkt) return;
const dx = punkt.clientX - start.x;
const dy = punkt.clientY - start.y;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy)) return; // kein eindeutiges Wischen
if (!umgedreht) {
setzeUmgedreht(true);
return;
}
void bewerten(dx > 0 ? 'sitzt' : 'nochmal');
}
if (fertig) {
return (
<div className="stapel">
<div className="karte-grund stapel" style={{ textAlign: 'center', padding: '2rem 1.25rem' }}>
<p style={{ fontSize: '2.5rem', margin: 0 }} aria-hidden="true">
✓
</p>
<h1 style={{ marginBottom: '0.2rem' }}>Stapel durch</h1>
<p className="leise">
{erledigt} {erledigt === 1 ? 'Karte' : 'Karten'} bewertet.
</p>
<ul className="bewertung-bilanz">
<li>
<strong>{zaehler.sitzt}</strong> <span>sitzt</span>
</li>
<li>
<strong>{zaehler.unsicher}</strong> <span>unsicher</span>
</li>
<li>
<strong>{zaehler.nochmal}</strong> <span>nochmal</span>
</li>
</ul>
<p style={{ marginBottom: 0 }}>
Morgen wartet der nächste Stapel. Wer täglich zehn Minuten macht, hat vor der Prüfung nichts
nachzuholen.
</p>
<div className="reihe" style={{ justifyContent: 'center' }}>
<Link href="/karten" className="knopf">
Zurück zur Übersicht
</Link>
<Link href="/" className="knopf knopf-zweit">
Zur Woche
</Link>
</div>
</div>
</div>
);
}
const uebrig = stapel.length - stelle;
return (
<div className="lernmodus">
<div className="lernmodus-kopf nicht-drucken">
<Link href="/karten" className="knopf knopf-still">
← Abbrechen
</Link>
<span className="leise">
{block ? `${karte.blockTitel} · ` : ''}
noch {uebrig} {uebrig === 1 ? 'Karte' : 'Karten'}
</span>
</div>
<div
className="balken"
role="progressbar"
aria-valuenow={erledigt}
aria-valuemin={0}
aria-valuemax={erledigt + uebrig}
aria-label={`${erledigt} von ${erledigt + uebrig} Karten bewertet`}
>
<span style={{ width: `${Math.round((erledigt / (erledigt + uebrig)) * 100)}%` }} />
</div>
<button
type="button"
className={`lernkarte${umgedreht ? ' lernkarte-offen' : ''}`}
onClick={() => !umgedreht && setzeUmgedreht(true)}
onTouchStart={beiBeruehrungStart}
onTouchEnd={beiBeruehrungEnde}
aria-label={umgedreht ? 'Antwort' : 'Frage – zum Umdrehen antippen'}
aria-expanded={umgedreht}
>
<span className="lernkarte-herkunft">
<span className="handout-kuerzel">{karte.handoutId}</span>
<span className="leise">{karte.handoutTitel}</span>
</span>
<span className="lernkarte-frage">{karte.frage}</span>
{umgedreht ? (
<>
<span className="lernkarte-trenner" aria-hidden="true" />
<span className="lernkarte-antwort">{karte.antwort}</span>
</>
) : (
<span className="lernkarte-hinweis">
Antworte erst laut oder im Kopf.
<br />
<strong>Antippen zum Umdrehen</strong>
<span className="nur-am-rechner"> · oder Leertaste</span>
</span>
)}
</button>
{umgedreht ? (
<div className="bewerten">
{knoepfe.map((knopf) => (
<button
key={knopf.bewertung}
type="button"
className={`bewerten-knopf ${knopf.klasse}`}
onClick={() => void bewerten(knopf.bewertung)}
disabled={laeuft}
>
<span className="bewerten-text">{knopf.text}</span>
<span className="bewerten-erklaerung">{knopf.erklaerung}</span>
<span className="bewerten-taste nur-am-rechner" aria-hidden="true">
{knopf.taste}
</span>
</button>
))}
</div>
) : (
<p className="leise lernmodus-fuss">
Wischen: nach rechts für &bdquo;sitzt&ldquo;, nach links für &bdquo;nochmal&ldquo;.
</p>
)}
<p aria-live="polite" className="lernmodus-rueckmeldung">
{rueckmeldung?.wiederAm ? `Zuletzt: kommt ${rueckmeldung.wiederAm}.` : ''}
{rueckmeldung?.fehler ? rueckmeldung.fehler : ''}
</p>
</div>
);
}
/**
* Eine Bewertung absetzen – und wenn kein Netz da ist, sie merken.
*
* Ohne Netz scheitert die Serveraktion mit einem Netzfehler. Dann geht die
* Bewertung in die Warteschlange und wird nachgereicht, sobald die Verbindung
* wieder steht. Wann die Karte dann wiederkommt, sagt der Server; hier wird
* bewusst nichts ausgerechnet, was später anders ausfallen könnte.
*/
async function bewertungAbsetzen(
benutzerId: string,
karteId: string,
bewertung: Bewertung,
): Promise<Bewertungsergebnis> {
try {
return await karteBewerten(karteId, bewertung);
} catch {
einreihen(benutzerId, { karteId, bewertung, erfasstAm: heutigesDatum() });
window.dispatchEvent(new Event('lernbar-warteschlange'));
return { wiederAm: 'gemerkt – geht ab, sobald wieder Netz da ist' };
}
}
/** Der heutige Tag nach der Uhr des Geräts, als `JJJJ-MM-TT`. */
function heutigesDatum(): string {
const jetzt = new Date();
const zweistellig = (zahl: number) => String(zahl).padStart(2, '0');
return `${jetzt.getFullYear()}-${zweistellig(jetzt.getMonth() + 1)}-${zweistellig(jetzt.getDate())}`;
}
@@ -0,0 +1,77 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { alsKartenstand, staendeVon } from '@/lib/repos/karten';
import { fach, fachNamen, heutigerStapel } from '@/lib/lernen/sm2';
import { heute } from '@/lib/zeit';
import { Lernmodus, type Lernkarte } from './Lernmodus';
export const metadata: Metadata = { title: 'Lernmodus' };
export const dynamic = 'force-dynamic';
const STAPELGROESSE = 20;
export default async function LernSeite({ searchParams }: { searchParams: Promise<{ block?: string }> }) {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') redirect('/karten');
const { block } = await searchParams;
const alle = inhalte();
const gewaehlterBlock = block && alle.blockNach.has(block) ? block : undefined;
const staende = staendeVon(benutzer.id);
const heuteDatum = heute();
const faellige = heutigerStapel(
alle.karten.map((karte) => ({
karteId: karte.id,
faelligAm: staende.get(karte.id)?.faelligAm ?? null,
block: karte.block,
})),
heuteDatum,
STAPELGROESSE,
gewaehlterBlock,
);
const karten: Lernkarte[] = faellige.map((eintrag) => {
const karte = alle.karteNach.get(eintrag.karteId)!;
const stand = alsKartenstand(staende.get(karte.id));
return {
id: karte.id,
frage: karte.frage,
antwort: karte.antwort,
handoutId: karte.handoutId,
handoutTitel: karte.handoutTitel,
blockTitel: alle.blockNach.get(karte.block)?.titel ?? karte.block,
fach: fach(stand),
fachName: fachNamen[fach(stand)] ?? '',
};
});
if (karten.length === 0) {
return (
<div className="stapel">
<div className="karte-grund stapel" style={{ textAlign: 'center', padding: '2rem 1.25rem' }}>
<p style={{ fontSize: '2.5rem', margin: 0 }} aria-hidden="true">
✓
</p>
<h1 style={{ marginBottom: '0.2rem' }}>Für heute nichts fällig</h1>
<p className="leise" style={{ marginBottom: 0 }}>
{gewaehlterBlock
? 'In diesem Themenblock ist heute keine Karte dran.'
: 'Alle Karten sitzen gerade fest genug. Morgen kommt der nächste Stapel.'}
</p>
<div className="reihe" style={{ justifyContent: 'center' }}>
<Link href="/karten" className="knopf">
Zurück zur Übersicht
</Link>
</div>
</div>
</div>
);
}
return <Lernmodus karten={karten} block={gewaehlterBlock ?? null} benutzerId={benutzer.id} />;
}
+190
View File
@@ -0,0 +1,190 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { alsKartenstand, staendeVon, uebersicht } from '@/lib/repos/karten';
import { fach, fachNamen, heutigerStapel } from '@/lib/lernen/sm2';
import { alsDatumText, heute } from '@/lib/zeit';
export const metadata: Metadata = { title: 'Merkkarten' };
export const dynamic = 'force-dynamic';
const STAPELGROESSE = 20;
export default async function KartenSeite() {
const benutzer = await erfordereBenutzer();
const alle = inhalte();
const heuteDatum = heute();
const istAzubi = benutzer.rolle === 'azubi';
const staende = istAzubi ? staendeVon(benutzer.id) : new Map();
const stand = istAzubi
? uebersicht(benutzer.id, alle.karten.map((k) => k.id), heuteDatum)
: { gesamt: alle.karten.length, gelernt: 0, faellig: 0, nieGelernt: alle.karten.length, jeFach: { 1: alle.karten.length, 2: 0, 3: 0, 4: 0, 5: 0 } };
const faelligeAlle = heutigerStapel(
alle.karten.map((karte) => ({
karteId: karte.id,
faelligAm: staende.get(karte.id)?.faelligAm ?? null,
block: karte.block,
})),
heuteDatum,
9999,
);
const jeBlock = alle.bloecke.map((block) => {
const desBlocks = alle.karten.filter((k) => k.block === block.id);
const faellig = faelligeAlle.filter((f) => f.block === block.id).length;
const gelernt = desBlocks.filter((k) => {
const zeile = staende.get(k.id);
return zeile ? fach(alsKartenstand(zeile)) >= 4 : false;
}).length;
return { block, gesamt: desBlocks.length, faellig, gelernt };
});
if (!istAzubi) {
return (
<div className="stapel">
<div>
<h1>Merkkarten</h1>
<p className="leise">
{alle.karten.length} Karten aus {alle.handouts.length} Handouts. Gelernt wird nur vom Azubi – du
siehst hier, was es gibt.
</p>
</div>
<div className="raster">
{jeBlock.map(({ block, gesamt }) => (
<section key={block.id} className="karte">
<h2 style={{ fontSize: '1rem', marginBottom: '0.2rem' }}>{block.titel}</h2>
<p className="leise" style={{ marginBottom: 0 }}>
{gesamt} {gesamt === 1 ? 'Karte' : 'Karten'}
</p>
</section>
))}
</div>
</div>
);
}
const heuteStapel = Math.min(stand.faellig, STAPELGROESSE);
return (
<div className="stapel">
<div>
<h1>Merkkarten</h1>
<p className="leise">
{stand.gesamt} Karten insgesamt · {stand.gelernt} sitzen · {stand.nieGelernt} noch nie gelernt
</p>
</div>
<section className="karte-grund stapel" aria-labelledby="heute">
<h2 id="heute" style={{ marginBottom: 0 }}>
Heute fällig
</h2>
{stand.faellig === 0 ? (
<p style={{ marginBottom: 0 }}>
Nichts. Alle Karten liegen weiter hinten – morgen ist wieder etwas dran.
</p>
) : (
<>
<p style={{ marginBottom: 0 }}>
<strong style={{ fontSize: '2rem' }}>{heuteStapel}</strong>{' '}
{heuteStapel === 1 ? 'Karte' : 'Karten'}
{stand.faellig > STAPELGROESSE ? (
<span className="leise"> von {stand.faellig} fälligen – der Rest kommt morgen</span>
) : null}
</p>
<div>
<Link href="/karten/lernen" className="knopf knopf-akzent">
Lernen starten
</Link>
</div>
</>
)}
</section>
<section className="stapel" aria-labelledby="bloecke">
<div>
<h2 id="bloecke" style={{ marginBottom: '0.2rem' }}>
Nach Themenblock
</h2>
<p className="leise" style={{ margin: 0 }}>
Vor einer Lernzielkontrolle gezielt üben – hier sind auch Karten dabei, die eigentlich erst später
dran wären.
</p>
</div>
<div className="raster">
{jeBlock.map(({ block, gesamt, faellig, gelernt }) => (
<section key={block.id} className="karte stapel-eng">
<h3 style={{ fontSize: '1rem', marginBottom: '0.2rem' }}>{block.titel}</h3>
<p className="leise" style={{ margin: 0 }}>
{gesamt} Karten · {gelernt} sitzen · {faellig} heute fällig
</p>
<div className="balken" aria-hidden="true">
<span style={{ width: `${gesamt === 0 ? 0 : Math.round((gelernt / gesamt) * 100)}%` }} />
</div>
<div>
<Link
href={`/karten/lernen?block=${block.id}`}
className={`knopf ${faellig > 0 ? '' : 'knopf-zweit'}`}
>
{faellig > 0 ? `${Math.min(faellig, STAPELGROESSE)} lernen` : 'Trotzdem üben'}
</Link>
</div>
</section>
))}
</div>
</section>
<section className="karte stapel-eng" aria-labelledby="faecher">
<h2 id="faecher" style={{ fontSize: '1rem', marginBottom: '0.2rem' }}>
Wie fest die Karten sitzen
</h2>
<ul className="faecher">
{[1, 2, 3, 4, 5].map((nummer) => {
const anzahl = stand.jeFach[nummer] ?? 0;
const anteil = stand.gesamt === 0 ? 0 : Math.round((anzahl / stand.gesamt) * 100);
return (
<li key={nummer}>
<span className="fach-nummer">{nummer}</span>
<span className="fach-name">{fachNamen[nummer]}</span>
<span className="fach-balken" aria-hidden="true">
<span style={{ width: `${anteil}%` }} />
</span>
<span className="fach-zahl">{anzahl}</span>
</li>
);
})}
</ul>
</section>
{stand.gesamt > stand.nieGelernt ? (
<section className="karte" aria-labelledby="naechste">
<h2 id="naechste" style={{ fontSize: '1rem', marginBottom: '0.4rem' }}>
Als Nächstes fällig
</h2>
<ul className="stapel-eng" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{[...staende.values()]
.filter((z) => z.faelligAm > heuteDatum)
.sort((a, b) => a.faelligAm.localeCompare(b.faelligAm))
.slice(0, 5)
.map((z) => {
const karte = alle.karteNach.get(z.karteId);
if (!karte) return null;
return (
<li key={z.karteId} className="reihe" style={{ gap: '0.5rem', flexWrap: 'nowrap' }}>
<span className="handout-kuerzel">{karte.handoutId}</span>
<span style={{ flex: 1 }}>{karte.frage}</span>
<span className="leise">{alsDatumText(z.faelligAm)}</span>
</li>
);
})}
</ul>
</section>
) : null}
</div>
);
}
@@ -0,0 +1,140 @@
'use client';
import { useActionState, useEffect, useRef, useState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { antwortZwischenspeichern, kontrolleAbgeben, type Rueckmeldung } from './aktionen';
import type { SichtbareFrage } from '@/lib/kontrollen/sicht';
/**
* Der Fragebogen.
*
* Antworten werden beim Tippen zwischengespeichert – eine Kontrolle dauert
* 45 Minuten, und ein verlorener Text nach einem versehentlichen Schließen
* wäre nicht wiedergutzumachen.
*
* Zur Musterlösung: Sie steckt gar nicht in den Daten, die diese Komponente
* bekommt, solange nicht abgegeben wurde. Das entscheidet der Server.
*/
export function Fragebogen({
kontrolleId,
fragen,
anfangsantworten,
gesamtpunkte,
dauerMinuten,
}: {
kontrolleId: string;
fragen: SichtbareFrage[];
anfangsantworten: Record<string, string>;
gesamtpunkte: number;
dauerMinuten: number | null;
}) {
const [antworten, setzeAntworten] = useState<Record<string, string>>(anfangsantworten);
const [gespeichert, setzeGespeichert] = useState<string | null>(null);
const [speicherfehler, setzeSpeicherfehler] = useState<string | null>(null);
const [zustand, absenden] = useActionState<Rueckmeldung, FormData>(kontrolleAbgeben, {});
const zeitgeber = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
useEffect(() => {
const laufende = zeitgeber.current;
return () => {
for (const gestellt of Object.values(laufende)) clearTimeout(gestellt);
};
}, []);
function aendern(frageId: string, wert: string) {
setzeAntworten((v) => ({ ...v, [frageId]: wert }));
clearTimeout(zeitgeber.current[frageId]);
zeitgeber.current[frageId] = setTimeout(async () => {
const ergebnis = await antwortZwischenspeichern(kontrolleId, frageId, wert);
if ('fehler' in ergebnis) {
setzeSpeicherfehler(ergebnis.fehler);
} else {
setzeSpeicherfehler(null);
setzeGespeichert(
new Intl.DateTimeFormat('de-DE', { timeStyle: 'medium', timeZone: 'Europe/Berlin' }).format(
new Date(ergebnis.gespeichertUm),
),
);
}
}, 900);
}
const beantwortet = fragen.filter((f) => (antworten[f.id] ?? '').trim() !== '').length;
return (
<form action={absenden} className="stapel">
<input type="hidden" name="kontrolleId" value={kontrolleId} />
<div className="kontrolle-kopf karte">
<div>
<strong>
{beantwortet} von {fragen.length} Fragen beantwortet
</strong>
<span className="leise" style={{ display: 'block' }}>
{gesamtpunkte} Punkte insgesamt
{dauerMinuten ? ` · ${dauerMinuten} Minuten vorgesehen` : ''}
</span>
</div>
<span className="leise speicherstand" aria-live="polite">
{speicherfehler ? (
<span style={{ color: 'var(--fehler)' }}>{speicherfehler}</span>
) : gespeichert ? (
`Zwischengespeichert um ${gespeichert}`
) : (
'Wird beim Tippen zwischengespeichert'
)}
</span>
</div>
<ol className="fragenliste">
{fragen.map((frage, stelle) => (
<li key={frage.id}>
<div className="frage-kopf">
<span className="frage-nummer">{stelle + 1}</span>
<p className="frage-text">{frage.frage}</p>
<span className="frage-punkte">
{frage.punkte} {frage.punkte === 1 ? 'Punkt' : 'Punkte'}
</span>
</div>
<label htmlFor={`antwort-${frage.id}`} className="nur-lesehilfe">
Antwort auf Frage {stelle + 1}
</label>
<textarea
id={`antwort-${frage.id}`}
name={`antwort-${frage.id}`}
rows={5}
maxLength={20000}
value={antworten[frage.id] ?? ''}
onChange={(e) => aendern(frage.id, e.target.value)}
placeholder="Antworte in ganzen Sätzen – in der Abschlussprüfung wird ebenfalls nicht in Stichworten bewertet."
/>
</li>
))}
</ol>
<Meldung zustand={zustand} />
<section className="karte-grund stapel-eng">
<p style={{ margin: 0 }}>
<strong>Abgeben ist endgültig.</strong> Danach lässt sich nichts mehr ändern – dafür siehst du die
Musterlösungen.
</p>
{beantwortet < fragen.length ? (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
{fragen.length - beantwortet}{' '}
{fragen.length - beantwortet === 1 ? 'Frage ist' : 'Fragen sind'} noch unbeantwortet.
</p>
) : null}
<div>
<Absenden className="knopf knopf-akzent" wartetext="Wird abgegeben …">
Kontrolle abgeben
</Absenden>
</div>
</section>
</form>
);
}
@@ -0,0 +1,157 @@
'use client';
import { useActionState, useState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { kontrolleKorrigieren, type Rueckmeldung } from './aktionen';
import { noteZu } from '@/lib/kontrollen/bewertung';
export type Korrekturfrage = {
id: string;
frage: string;
punkte: number;
musterloesung: string;
antwort: string;
vergeben: number | null;
anmerkung: string;
};
/**
* Korrekturansicht des Ausbilders.
*
* Je Frage: Fragetext, die Antwort des Azubis, die Musterlösung daneben, ein
* Punktefeld und ein Feld für eine Anmerkung. Die Zwischensumme und die daraus
* folgende Note laufen beim Tippen mit, damit man beim Verteilen der Punkte
* sieht, wo man landet.
*/
export function Korrektur({
kontrolleId,
azubiId,
fragen,
anmerkung,
}: {
kontrolleId: string;
azubiId: string;
fragen: Korrekturfrage[];
anmerkung: string;
}) {
const [punkte, setzePunkte] = useState<Record<string, string>>(() =>
Object.fromEntries(fragen.map((f) => [f.id, f.vergeben === null ? '' : String(f.vergeben)])),
);
const [zustand, korrigieren] = useActionState<Rueckmeldung, FormData>(kontrolleKorrigieren, {});
const moeglich = fragen.reduce((summe, f) => summe + f.punkte, 0);
const erreicht = fragen.reduce((summe, f) => {
const wert = Number((punkte[f.id] ?? '').replace(',', '.'));
return summe + (Number.isFinite(wert) && wert > 0 ? Math.min(wert, f.punkte) : 0);
}, 0);
const offen = fragen.filter((f) => (punkte[f.id] ?? '').trim() === '').length;
const prozent = moeglich > 0 ? Math.round((erreicht / moeglich) * 100) : 0;
const note = noteZu(prozent);
return (
<form action={korrigieren} className="stapel">
<input type="hidden" name="kontrolleId" value={kontrolleId} />
<input type="hidden" name="azubiId" value={azubiId} />
<div className="kontrolle-kopf karte" style={{ position: 'sticky', top: 0, zIndex: 5 }}>
<div>
<strong>
{runde(erreicht)} von {moeglich} Punkten · {prozent} %
</strong>
<span className="leise" style={{ display: 'block' }}>
{offen > 0
? `${offen} ${offen === 1 ? 'Frage' : 'Fragen'} noch ohne Punkte – Ergebnis vorläufig`
: `Note ${note.note} (${note.text})`}
</span>
</div>
<span className={`note-anzeige${offen > 0 ? ' note-vorlaeufig' : ''}`} aria-hidden="true">
{note.note}
</span>
</div>
<ol className="fragenliste">
{fragen.map((frage, stelle) => (
<li key={frage.id}>
<div className="frage-kopf">
<span className="frage-nummer">{stelle + 1}</span>
<p className="frage-text">{frage.frage}</p>
<span className="frage-punkte">
{frage.punkte} {frage.punkte === 1 ? 'Punkt' : 'Punkte'}
</span>
</div>
<div className="korrektur-spalten">
<div>
<span className="wochentag-marke">Antwort des Azubis</span>
{frage.antwort.trim() ? (
<p className="notiz-anzeige">{frage.antwort}</p>
) : (
<p className="leise" style={{ margin: 0 }}>
Nicht beantwortet.
</p>
)}
</div>
<div>
<span className="wochentag-marke">Musterlösung</span>
<p className="notiz-anzeige musterloesung">{frage.musterloesung}</p>
</div>
</div>
<div className="korrektur-bewertung">
<div>
<label htmlFor={`punkte-${frage.id}`}>Punkte (von {frage.punkte})</label>
<input
id={`punkte-${frage.id}`}
name={`punkte-${frage.id}`}
type="number"
min={0}
max={frage.punkte}
step={0.5}
value={punkte[frage.id] ?? ''}
onChange={(e) => setzePunkte((v) => ({ ...v, [frage.id]: e.target.value }))}
/>
</div>
<div style={{ flex: 1 }}>
<label htmlFor={`anmerkung-${frage.id}`}>Anmerkung zu dieser Frage</label>
<input
id={`anmerkung-${frage.id}`}
name={`anmerkung-${frage.id}`}
type="text"
maxLength={500}
defaultValue={frage.anmerkung}
placeholder="z. B. Rechenweg fehlt"
/>
</div>
</div>
</li>
))}
</ol>
<section className="karte-grund stapel-eng">
<label htmlFor="gesamtanmerkung">Anmerkung zur ganzen Kontrolle</label>
<textarea
id="gesamtanmerkung"
name="anmerkung"
rows={3}
maxLength={4000}
defaultValue={anmerkung}
placeholder="Was lief gut, woran sollte er arbeiten?"
/>
<Meldung zustand={zustand} />
<div>
<Absenden className="knopf knopf-akzent">
{offen > 0 ? 'Zwischenstand speichern' : 'Korrektur abschließen'}
</Absenden>
</div>
</section>
</form>
);
}
function runde(wert: number): number {
return Math.round(wert * 100) / 100;
}
@@ -0,0 +1,256 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { inhalte } from '@/lib/inhalte';
import { antwortenVon, ergebnisVon } from '@/lib/repos/kontrollen';
import { darfFragenSehen, sichtbareKontrolle, zustandVon } from '@/lib/kontrollen/sicht';
import { alsZeitpunktText } from '@/lib/zeit';
import { Fragebogen } from '../Fragebogen';
import { Korrektur, type Korrekturfrage } from '../Korrektur';
import { standZumLesen } from '../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ kontrolleId: string }> }): Promise<Metadata> {
const { kontrolleId } = await params;
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
return { title: inhalt?.titel ?? 'Lernzielkontrolle' };
}
export default async function KontrollSeite({
params,
searchParams,
}: {
params: Promise<{ kontrolleId: string }>;
searchParams: Promise<{ azubi?: string }>;
}) {
const benutzer = await erfordereBenutzer();
const { kontrolleId } = await params;
const { azubi: azubiParameter } = await searchParams;
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
if (!inhalt) notFound();
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (azubiParameter ?? '');
if (!azubiId) notFound();
const gelesen = await standZumLesen(azubiId, kontrolleId);
if (!gelesen) notFound();
const { bezug, stand } = gelesen;
const istAzubi = benutzer.rolle === 'azubi';
const azubi = istAzubi ? benutzer : benutzerNachId(azubiId);
if (!azubi) notFound();
const bearbeitungsstand = {
azubiId,
freigegebenAm: stand.freigegebenAm,
abgegebenAm: stand.abgegebenAm,
};
// Vor der Freigabe bekommt der Azubi die Fragen gar nicht erst zu sehen.
if (!darfFragenSehen(benutzer, bearbeitungsstand, bezug)) {
return (
<div className="stapel">
<nav className="leise nicht-drucken">
<Link href="/kontrollen">Lernzielkontrollen</Link>
</nav>
<div className="karte-grund stapel" style={{ textAlign: 'center', padding: '2rem 1.25rem' }}>
<p style={{ fontSize: '2.2rem', margin: 0 }} aria-hidden="true">
🔒
</p>
<h1 style={{ marginBottom: '0.2rem' }}>Noch nicht freigegeben</h1>
<p className="leise" style={{ marginBottom: 0 }}>
Dein Ausbilder gibt diese Kontrolle frei, wenn es soweit ist. Bis dahin sind die Fragen nicht
einsehbar.
</p>
<div className="reihe" style={{ justifyContent: 'center' }}>
<Link href="/kontrollen" className="knopf">
Zurück zur Übersicht
</Link>
</div>
</div>
</div>
);
}
// Hier wird die Musterlösung entfernt, falls sie nicht gezeigt werden darf.
const sicht = sichtbareKontrolle(inhalt, benutzer, bearbeitungsstand, bezug);
const antworten = antwortenVon(stand.id);
const zustand = zustandVon({ ...bearbeitungsstand, korrigiertAm: stand.korrigiertAm });
const ergebnis = stand.abgegebenAm ? ergebnisVon(stand.id, inhalt) : null;
const korrektor = stand.korrigiertVon ? benutzerNachId(stand.korrigiertVon) : null;
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/kontrollen">Lernzielkontrollen</Link>
{!istAzubi ? ` › ${azubi.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="kontroll-woche kontroll-woche-gross">Woche {inhalt.woche}</span>
{ergebnis && stand.korrigiertAm ? (
<span className="marke-status" data-ton="gut">
✓ {zahl(ergebnis.erreicht)} von {zahl(ergebnis.moeglich)} Punkten · {ergebnis.prozent} % · Note{' '}
{ergebnis.note}
</span>
) : null}
{zustand === 'abgegeben' ? (
<span className="marke-status" data-ton="warnung">
▲ abgegeben {alsZeitpunktText(stand.abgegebenAm)}
</span>
) : null}
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{inhalt.titel}</h1>
{inhalt.hinweis ? <p className="handout-worum">{inhalt.hinweis}</p> : null}
{!istAzubi ? <p className="leise">{azubi.name}</p> : null}
</header>
{/* --- Ergebnis, sobald korrigiert --- */}
{ergebnis && stand.korrigiertAm ? (
<section className="karte stapel-eng" aria-labelledby="ergebnis">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="ergebnis" style={{ margin: 0, fontSize: '1rem' }}>
Ergebnis
</h2>
<span className="leise">
korrigiert {alsZeitpunktText(stand.korrigiertAm)}
{korrektor ? ` von ${korrektor.name}` : ''}
</span>
</div>
<div className="ergebnis-balken">
<div className="balken" style={{ height: '14px' }}>
<span style={{ width: `${ergebnis.prozent}%` }} />
</div>
<strong>
{zahl(ergebnis.erreicht)} / {zahl(ergebnis.moeglich)} · {ergebnis.prozent} % · Note {ergebnis.note}{' '}
({ergebnis.notentext})
</strong>
</div>
{stand.anmerkung ? <p className="notiz-anzeige">{stand.anmerkung}</p> : null}
</section>
) : null}
{/* --- Arbeitsansicht --- */}
{istAzubi && zustand === 'freigegeben' ? (
<Fragebogen
kontrolleId={inhalt.id}
fragen={sicht.fragen}
anfangsantworten={Object.fromEntries([...antworten].map(([id, a]) => [id, a.antwort]))}
gesamtpunkte={sicht.gesamtpunkte}
dauerMinuten={sicht.dauerMinuten}
/>
) : null}
{/* --- Nach der Abgabe: Antworten und Musterlösungen --- */}
{istAzubi && stand.abgegebenAm ? (
<ol className="fragenliste">
{sicht.fragen.map((frage, stelle) => {
const eigene = antworten.get(frage.id);
return (
<li key={frage.id}>
<div className="frage-kopf">
<span className="frage-nummer">{stelle + 1}</span>
<p className="frage-text">{frage.frage}</p>
<span className="frage-punkte">
{eigene?.punkte != null ? `${zahl(eigene.punkte)} / ` : ''}
{frage.punkte} {frage.punkte === 1 ? 'Punkt' : 'Punkte'}
</span>
</div>
<div className="korrektur-spalten">
<div>
<span className="wochentag-marke">Deine Antwort</span>
{eigene?.antwort.trim() ? (
<p className="notiz-anzeige">{eigene.antwort}</p>
) : (
<p className="leise" style={{ margin: 0 }}>
Nicht beantwortet.
</p>
)}
</div>
<div>
<span className="wochentag-marke">Musterlösung</span>
<p className="notiz-anzeige musterloesung">{frage.musterloesung}</p>
</div>
</div>
{eigene?.anmerkung ? (
<p className="hinweis hinweis-warnung" style={{ marginTop: '0.5rem', marginBottom: 0 }}>
{eigene.anmerkung}
</p>
) : null}
</li>
);
})}
</ol>
) : null}
{/* --- Korrektur durch den Ausbilder --- */}
{!istAzubi && stand.abgegebenAm ? (
<Korrektur
kontrolleId={inhalt.id}
azubiId={azubiId}
anmerkung={stand.anmerkung ?? ''}
fragen={inhalt.fragen.map<Korrekturfrage>((frage) => ({
id: frage.id,
frage: frage.frage,
punkte: frage.punkte,
musterloesung: frage.musterloesung,
antwort: antworten.get(frage.id)?.antwort ?? '',
vergeben: antworten.get(frage.id)?.punkte ?? null,
anmerkung: antworten.get(frage.id)?.anmerkung ?? '',
}))}
/>
) : null}
{/* --- Ausbilder vor der Abgabe: nur durchsehen --- */}
{!istAzubi && !stand.abgegebenAm ? (
<>
<p className="hinweis hinweis-warnung">
{zustand === 'gesperrt'
? 'Diese Kontrolle ist für den Azubi noch nicht freigegeben – du siehst sie zum Durchsehen.'
: 'Freigegeben, aber noch nicht abgegeben. Die Antworten erscheinen hier, sobald der Azubi abgibt.'}
</p>
<ol className="fragenliste">
{sicht.fragen.map((frage, stelle) => (
<li key={frage.id}>
<div className="frage-kopf">
<span className="frage-nummer">{stelle + 1}</span>
<p className="frage-text">{frage.frage}</p>
<span className="frage-punkte">
{frage.punkte} {frage.punkte === 1 ? 'Punkt' : 'Punkte'}
</span>
</div>
<span className="wochentag-marke">Musterlösung</span>
<p className="notiz-anzeige musterloesung">{frage.musterloesung}</p>
</li>
))}
</ol>
</>
) : null}
<p className="leise nicht-drucken" style={{ marginBottom: 0 }}>
Quelle: <code>content/lernzielkontrollen/{inhalt.id}.yaml</code>
</p>
</article>
);
}
function zahl(wert: number): string {
return wert.toString().replace('.', ',');
}
+166
View File
@@ -0,0 +1,166 @@
'use server';
import { revalidatePath } from 'next/cache';
import { ausbildungVonAzubi, erfordereBenutzer, erfordereBewertungsrecht } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { inhalte } from '@/lib/inhalte';
import {
abgeben,
antwortSpeichern,
freigabeZuruecknehmen,
freigeben,
korrigieren,
sorgeFuerStand,
standVon,
} from '@/lib/repos/kontrollen';
import { darfBearbeiten } from '@/lib/kontrollen/sicht';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
function aktualisiere(kontrolleId: string) {
revalidatePath('/kontrollen');
revalidatePath(`/kontrollen/${kontrolleId}`);
revalidatePath('/');
}
/**
* Antwort zwischenspeichern.
*
* Wird beim Tippen mit kurzer Verzögerung aufgerufen. Gibt bewusst **nichts**
* zurück außer einem Zeitstempel – insbesondere keine Musterlösung, auch nicht
* versehentlich über einen mitgegebenen Datensatz.
*/
export async function antwortZwischenspeichern(
kontrolleId: string,
frageId: string,
antwort: string,
): Promise<{ gespeichertUm: string } | { fehler: string }> {
const benutzer = await erfordereBenutzer();
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
if (!inhalt) return { fehler: 'Diese Lernzielkontrolle gibt es nicht.' };
if (!inhalt.fragen.some((f) => f.id === frageId)) return { fehler: 'Diese Frage gibt es nicht.' };
if (antwort.length > 20000) return { fehler: 'Die Antwort ist zu lang.' };
const stand = standVon(benutzer.id, kontrolleId);
if (!stand) return { fehler: 'Die Kontrolle ist nicht freigegeben.' };
// Die eine Regel, auf die es ankommt: geschrieben wird nur zwischen
// Freigabe und Abgabe, und nur vom Azubi selbst.
if (!darfBearbeiten(benutzer, { azubiId: stand.benutzerId, freigegebenAm: stand.freigegebenAm, abgegebenAm: stand.abgegebenAm })) {
return { fehler: 'Diese Kontrolle lässt sich gerade nicht bearbeiten.' };
}
antwortSpeichern(stand.id, frageId, antwort);
return { gespeichertUm: new Date().toISOString() };
}
export async function kontrolleAbgeben(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
const kontrolleId = String(formular.get('kontrolleId') ?? '');
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
if (!inhalt) return { fehler: 'Diese Lernzielkontrolle gibt es nicht.' };
const stand = standVon(benutzer.id, kontrolleId);
if (!stand) return { fehler: 'Die Kontrolle ist nicht freigegeben.' };
if (!darfBearbeiten(benutzer, { azubiId: stand.benutzerId, freigegebenAm: stand.freigegebenAm, abgegebenAm: stand.abgegebenAm })) {
return { fehler: 'Diese Kontrolle lässt sich gerade nicht abgeben.' };
}
// Letzten Stand der Antworten übernehmen, damit nichts verlorengeht.
for (const frage of inhalt.fragen) {
const antwort = formular.get(`antwort-${frage.id}`);
if (typeof antwort === 'string') antwortSpeichern(stand.id, frage.id, antwort);
}
abgeben(stand.id);
aktualisiere(kontrolleId);
return { erfolg: 'Abgegeben. Die Musterlösungen stehen jetzt unter den Fragen.' };
}
// --- Ausbilder -------------------------------------------------------------
export async function kontrolleFreigeben(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const kontrolleId = String(formular.get('kontrolleId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
if (!inhalte().kontrolleNach.has(kontrolleId)) return;
freigeben(azubiId, kontrolleId, ausbilder.id);
aktualisiere(kontrolleId);
}
export async function freigabeZurueck(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const kontrolleId = String(formular.get('kontrolleId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
const stand = standVon(azubiId, kontrolleId);
// Eine abgegebene Kontrolle wird nicht wieder zugemacht.
if (!stand || stand.abgegebenAm) return;
freigabeZuruecknehmen(stand.id);
aktualisiere(kontrolleId);
}
export async function kontrolleKorrigieren(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const kontrolleId = String(formular.get('kontrolleId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
if (!inhalt) return { fehler: 'Diese Lernzielkontrolle gibt es nicht.' };
const stand = standVon(azubiId, kontrolleId);
if (!stand) return { fehler: 'Zu dieser Kontrolle gibt es noch keinen Stand.' };
if (!stand.abgegebenAm) return { fehler: 'Die Kontrolle ist noch nicht abgegeben.' };
const bewertungen = inhalt.fragen.map((frage) => {
const roh = String(formular.get(`punkte-${frage.id}`) ?? '').replace(',', '.').trim();
const gelesen = Number(roh);
const anmerkung = String(formular.get(`anmerkung-${frage.id}`) ?? '').trim();
return {
frageId: frage.id,
punkte: roh === '' || !Number.isFinite(gelesen) ? null : gelesen,
anmerkung: anmerkung || null,
};
});
const gesamtanmerkung = String(formular.get('anmerkung') ?? '').trim();
if (gesamtanmerkung.length > 4000) return { fehler: 'Die Anmerkung ist zu lang.' };
const ergebnis = korrigieren(stand.id, ausbilder.id, inhalt, bewertungen, gesamtanmerkung || null);
aktualisiere(kontrolleId);
return ergebnis.vollstaendig
? {
erfolg: `Korrigiert: ${zahl(ergebnis.erreicht)} von ${zahl(ergebnis.moeglich)} Punkten – ${ergebnis.prozent} %, Note ${ergebnis.note} (${ergebnis.notentext}).`,
}
: {
erfolg: `Zwischenstand gespeichert. ${ergebnis.offeneFragen} ${ergebnis.offeneFragen === 1 ? 'Frage ist' : 'Fragen sind'} noch ohne Punkte – erst dann gilt die Kontrolle als korrigiert.`,
};
}
/** Prüft den Lesezugriff und liefert den Stand. Wird von Seiten benutzt. */
export async function standZumLesen(azubiId: string, kontrolleId: string) {
const benutzer = await erfordereBenutzer();
const bezug = ausbildungVonAzubi(azubiId);
if (!darfAzubiDatenSehen(benutzer, azubiId, bezug)) return null;
const stand = sorgeFuerStand(azubiId, kontrolleId);
return { benutzer, bezug, stand };
}
function zahl(wert: number): string {
return wert.toString().replace('.', ',');
}
+211
View File
@@ -0,0 +1,211 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import { ergebnisVon, staendeVon } from '@/lib/repos/kontrollen';
import { gesamtpunkte, zustandVon, type Zustand } from '@/lib/kontrollen/sicht';
import { verlauf } from '@/lib/kontrollen/bewertung';
import { alsZeitpunktText } from '@/lib/zeit';
import { kontrolleFreigeben, freigabeZurueck } from './aktionen';
export const metadata: Metadata = { title: 'Lernzielkontrollen' };
export const dynamic = 'force-dynamic';
const zustandsmarke: Record<Zustand, { text: string; ton: string; zeichen: string }> = {
gesperrt: { text: 'nicht freigegeben', ton: 'neutral', zeichen: '○' },
freigegeben: { text: 'freigegeben', ton: 'warnung', zeichen: '▶' },
abgegeben: { text: 'abgegeben', ton: 'warnung', zeichen: '▲' },
korrigiert: { text: 'korrigiert', ton: 'gut', zeichen: '✓' },
};
export default async function KontrollenSeite() {
const benutzer = await erfordereBenutzer();
const alle = inhalte();
if (benutzer.rolle === 'azubi') {
const staende = staendeVon(benutzer.id);
const zeilen = alle.kontrollen.map((inhalt) => {
const stand = staende.get(inhalt.id);
const zustand = zustandVon({
azubiId: benutzer.id,
freigegebenAm: stand?.freigegebenAm ?? null,
abgegebenAm: stand?.abgegebenAm ?? null,
korrigiertAm: stand?.korrigiertAm ?? null,
});
const ergebnis = stand && stand.korrigiertAm ? ergebnisVon(stand.id, inhalt) : null;
return { inhalt, zustand, ergebnis, stand };
});
const korrigierte = zeilen
.filter((z) => z.ergebnis)
.map((z) => ({ titel: z.inhalt.titel, prozent: z.ergebnis!.prozent, note: z.ergebnis!.note }));
const stand = verlauf(korrigierte);
return (
<div className="stapel">
<div>
<h1>Lernzielkontrollen</h1>
<p className="leise">
{alle.kontrollen.length} Kontrollen · {korrigierte.length} korrigiert
{stand.anzahl > 0 ? ` · Schnitt ${stand.schnittProzent} %, Note ${zahl(stand.schnittNote)}` : ''}
</p>
</div>
<ul className="kontrollliste">
{zeilen.map(({ inhalt, zustand, ergebnis }) => {
const marke = zustandsmarke[zustand];
const offen = zustand === 'gesperrt';
return (
<li key={inhalt.id}>
{offen ? (
<div className="kontrollzeile kontrollzeile-gesperrt">
<Zeileninhalt inhalt={inhalt} marke={marke} ergebnis={ergebnis} />
</div>
) : (
<Link href={`/kontrollen/${inhalt.id}`} className="kontrollzeile">
<Zeileninhalt inhalt={inhalt} marke={marke} ergebnis={ergebnis} />
</Link>
)}
</li>
);
})}
</ul>
<p className="leise">
Eine Kontrolle wird von deinem Ausbilder freigegeben. Vorher sind die Fragen nicht einsehbar – die
Musterlösungen erst nach dem Absenden.
</p>
</div>
);
}
// --- Ausbilder ---
const meine = ausbildungenVonAusbilder(benutzer.id);
return (
<div className="stapel">
<div>
<h1>Lernzielkontrollen</h1>
<p className="leise">Freigeben, korrigieren, Ergebnisse ansehen.</p>
</div>
{meine.length === 0 ? (
<p className="hinweis hinweis-warnung">
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
meine.map(({ azubi }) => {
const staende = staendeVon(azubi.id);
return (
<section key={azubi.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
{azubi.name}
</h2>
<ul className="kontrollliste">
{alle.kontrollen.map((inhalt) => {
const stand = staende.get(inhalt.id);
const zustand = zustandVon({
azubiId: azubi.id,
freigegebenAm: stand?.freigegebenAm ?? null,
abgegebenAm: stand?.abgegebenAm ?? null,
korrigiertAm: stand?.korrigiertAm ?? null,
});
const marke = zustandsmarke[zustand];
const ergebnis = stand && stand.abgegebenAm ? ergebnisVon(stand.id, inhalt) : null;
return (
<li key={inhalt.id}>
<div className="kontrollzeile">
<Link href={`/kontrollen/${inhalt.id}?azubi=${azubi.id}`} className="kontrollzeile-haupt">
<span className="kontroll-woche">Woche {inhalt.woche}</span>
<span className="kontroll-titel">
{inhalt.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{inhalt.fragen.length} Fragen · {gesamtpunkte(inhalt)} Punkte
{stand?.abgegebenAm ? ` · abgegeben ${alsZeitpunktText(stand.abgegebenAm)}` : ''}
</span>
</span>
</Link>
<div className="reihe" style={{ gap: '0.4rem' }}>
{ergebnis && stand?.korrigiertAm ? (
<span className="marke-status" data-ton="gut">
{ergebnis.prozent} % · Note {ergebnis.note}
</span>
) : null}
<span className="marke-status" data-ton={marke.ton}>
{marke.zeichen} {marke.text}
</span>
{zustand === 'gesperrt' ? (
<form action={kontrolleFreigeben}>
<input type="hidden" name="azubiId" value={azubi.id} />
<input type="hidden" name="kontrolleId" value={inhalt.id} />
<button type="submit" className="knopf knopf-akzent">
Freigeben
</button>
</form>
) : null}
{zustand === 'freigegeben' ? (
<form action={freigabeZurueck}>
<input type="hidden" name="azubiId" value={azubi.id} />
<input type="hidden" name="kontrolleId" value={inhalt.id} />
<button type="submit" className="knopf knopf-still">
Freigabe zurück
</button>
</form>
) : null}
</div>
</div>
</li>
);
})}
</ul>
</section>
);
})
)}
</div>
);
}
function Zeileninhalt({
inhalt,
marke,
ergebnis,
}: {
inhalt: { id: string; woche: number; titel: string; fragen: { punkte: number }[] };
marke: { text: string; ton: string; zeichen: string };
ergebnis: { prozent: number; note: number } | null;
}) {
return (
<>
<span className="kontroll-woche">Woche {inhalt.woche}</span>
<span className="kontroll-titel">
{inhalt.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{inhalt.fragen.length} Fragen · {inhalt.fragen.reduce((s, f) => s + f.punkte, 0)} Punkte
</span>
</span>
{ergebnis ? (
<span className="marke-status" data-ton="gut">
{ergebnis.prozent} % · Note {ergebnis.note}
</span>
) : null}
<span className="marke-status" data-ton={marke.ton}>
{marke.zeichen} {marke.text}
</span>
</>
);
}
function zahl(wert: number): string {
return wert.toString().replace('.', ',');
}
+17
View File
@@ -1,5 +1,6 @@
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { Kopfzeile, type Navigationseintrag } from '@/komponenten/Kopfzeile';
import { Netzzustand } from '@/komponenten/Netzzustand';
import { abmelden } from '../anmelden/aktionen';
export const dynamic = 'force-dynamic';
@@ -9,12 +10,27 @@ export const dynamic = 'force-dynamic';
const fuerAzubi: Navigationseintrag[] = [
{ pfad: '/', titel: 'Woche' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/aufgaben', titel: 'Aufgaben' },
{ pfad: '/checklisten', titel: 'Checklisten' },
{ pfad: '/berichtsheft', titel: 'Berichtsheft' },
{ pfad: '/kontrollen', titel: 'Kontrollen' },
{ pfad: '/auswertung', titel: 'Mein Stand' },
{ pfad: '/feedback', titel: 'Feedback' },
{ pfad: '/glossar', titel: 'Glossar' },
];
const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/', titel: 'Überblick' },
{ pfad: '/wochenplan', titel: 'Wochenplan' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/aufgaben', titel: 'Aufgaben' },
{ pfad: '/checklisten', titel: 'Checklisten' },
{ pfad: '/berichtsheft', titel: 'Berichtshefte' },
{ pfad: '/kontrollen', titel: 'Kontrollen' },
{ pfad: '/auswertung', titel: 'Auswertung' },
{ pfad: '/feedback', titel: 'Feedback' },
{ pfad: '/glossar', titel: 'Glossar' },
{ pfad: '/verwaltung', titel: 'Verwaltung' },
];
@@ -34,6 +50,7 @@ export default async function AngemeldetLayout({ children }: { children: React.R
rolle={benutzer.rolle === 'ausbilder' ? 'Ausbilder' : 'Azubi'}
abmeldeAktion={abmelden}
/>
<Netzzustand benutzerId={benutzer.id} />
<main id="inhalt" className="huelle">
{children}
</main>
+27
View File
@@ -0,0 +1,27 @@
import Link from 'next/link';
/**
* „Nicht gefunden" im angemeldeten Bereich.
*
* Diese Seite erscheint auch dann, wenn jemand Daten anfragt, die ihn nichts
* angehen. Die Meldung ist deshalb absichtlich unbestimmt: Ob es die Daten
* gibt, wird nicht verraten.
*/
export default function NichtGefunden() {
return (
<div className="karte-grund stapel" style={{ maxWidth: '36rem', margin: '2rem auto', textAlign: 'center' }}>
<p style={{ fontSize: '2.2rem', margin: 0 }} aria-hidden="true">
🔍
</p>
<h1 style={{ marginBottom: '0.2rem' }}>Nicht gefunden</h1>
<p className="leise">
Diese Seite gibt es nicht – oder sie gehört nicht zu deinem Bereich.
</p>
<div className="reihe" style={{ justifyContent: 'center' }}>
<Link href="/" className="knopf">
Zur Startseite
</Link>
</div>
</div>
);
}
+194 -43
View File
@@ -1,70 +1,221 @@
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungNachAzubi, ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { alsDatumText, kalenderwocheText, heute } from '@/lib/zeit';
import { Fortschrittsbalken } from '@/komponenten/Fortschrittsbalken';
import { aktuelleWochensicht, kurzstand } from '@/lib/planung/sicht';
import { alsDatumText, alsZeitpunktText, heute, kalenderwocheText } from '@/lib/zeit';
import { inhalte } from '@/lib/inhalte';
import { wartetAufAbnahme } from '@/lib/repos/aufgaben';
import { wartetAufAbzeichnung } from '@/lib/repos/berichtsheft';
import { wartetAufKorrektur } from '@/lib/repos/kontrollen';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { WochenAnsicht } from './WochenAnsicht';
export const dynamic = 'force-dynamic';
/**
* Vorläufige Startseite. Ab Baustein 3 steht hier der Wochenplan.
*/
export default async function StartSeite() {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle === 'ausbilder') {
const meine = ausbildungenVonAusbilder(benutzer.id);
return benutzer.rolle === 'ausbilder' ? (
<AusbilderUeberblick ausbilderId={benutzer.id} name={benutzer.name} />
) : (
<AzubiWoche azubiId={benutzer.id} name={benutzer.name} />
);
}
// --- Azubi: der Wochenplan ist die Startseite ------------------------------
function AzubiWoche({ azubiId, name }: { azubiId: string; name: string }) {
const ausbildung = ausbildungNachAzubi(azubiId);
if (!ausbildung) {
return (
<div className="stapel">
<div>
<h1>Guten Tag, {benutzer.name.split(' ')[0]}</h1>
<p className="leise">
{kalenderwocheText(heute())} · {alsDatumText(heute())}
</p>
</div>
<section className="karte stapel">
<h2 style={{ marginBottom: 0 }}>Ausbildungen</h2>
{meine.length === 0 ? (
<p style={{ marginBottom: 0 }}>
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{meine.map(({ ausbildung, azubi }) => (
<li key={ausbildung.id}>
<strong>{azubi.name}</strong> · seit {alsDatumText(ausbildung.beginn)}
</li>
))}
</ul>
)}
</section>
<h1>Guten Tag, {name.split(' ')[0]}</h1>
<p className="hinweis hinweis-warnung">
Zu deinem Konto ist noch keine Ausbildung hinterlegt. Sag deinem Ausbilder Bescheid – ohne
Ausbildungsverhältnis gibt es keinen Wochenplan.
</p>
</div>
);
}
const ausbildung = ausbildungNachAzubi(benutzer.id);
const sicht = aktuelleWochensicht(ausbildung.id, azubiId);
if (!sicht) {
return (
<div className="stapel">
<h1>Guten Tag, {name.split(' ')[0]}</h1>
<p className="hinweis hinweis-warnung">
Für deine Ausbildung ist noch kein Wochenplan angelegt. Dein Ausbilder kann ihn unter &bdquo;Wochenplan&ldquo;
erzeugen.
</p>
</div>
);
}
return <WochenAnsicht sicht={sicht} istAzubi wochenLink="/woche/{nr}" />;
}
// --- Ausbilder: wer steht wo -----------------------------------------------
function AusbilderUeberblick({ ausbilderId, name }: { ausbilderId: string; name: string }) {
const meine = ausbildungenVonAusbilder(ausbilderId);
return (
<div className="stapel">
<div>
<h1>Guten Tag, {benutzer.name.split(' ')[0]}</h1>
<h1>Guten Tag, {name.split(' ')[0]}</h1>
<p className="leise">
{kalenderwocheText(heute())} · {alsDatumText(heute())}
</p>
</div>
<section className="karte">
{ausbildung ? (
<p style={{ marginBottom: 0 }}>
Deine Ausbildung läuft seit {alsDatumText(ausbildung.beginn)}
{ausbildung.probezeitBis ? ` (Probezeit bis ${alsDatumText(ausbildung.probezeitBis)})` : ''}.
</p>
) : (
<p style={{ marginBottom: 0 }}>
Zu deinem Konto ist noch keine Ausbildung hinterlegt. Sag deinem Ausbilder Bescheid.
</p>
)}
</section>
{meine.length === 0 ? (
<p className="hinweis hinweis-warnung">
Noch kein Azubikonto angelegt. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
<section className="stapel" aria-labelledby="stand">
<h2 id="stand">Wo deine Auszubildenden stehen</h2>
{meine.map(({ ausbildung, azubi }) => {
const stand = kurzstand(ausbildung.id, azubi.id);
return (
<article key={ausbildung.id} className="karte stapel">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<div>
<h3 style={{ marginBottom: '0.1rem' }}>{azubi.name}</h3>
<p className="leise" style={{ margin: 0 }}>
Ausbildung seit {alsDatumText(ausbildung.beginn)}
{azubi.aktiv === 1 ? '' : ' · Konto gesperrt'}
</p>
</div>
<Link href={`/wochenplan?azubi=${azubi.id}`} className="knopf knopf-zweit">
Wochenplan
</Link>
</div>
{stand ? (
<>
<div className="karte-grund stapel-eng">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<strong>
Woche {stand.wochenNummer} · {stand.titel}
</strong>
<span className="leise">
{alsDatumText(stand.geplantVon)} – {alsDatumText(stand.geplantBis)}
</span>
</div>
<Fortschrittsbalken fortschritt={stand.fortschritt} beschriftung="Stand der Woche" />
</div>
{stand.fortschritt.offen.length > 0 ? (
<ul className="offen-liste">
{stand.fortschritt.offen.map((bereich) => (
<li key={bereich.schluessel}>
<span>{bereich.titel}</span>
<span className="leise">
{bereich.zaehlwerk.erledigt} von {bereich.zaehlwerk.gesamt}
</span>
</li>
))}
</ul>
) : null}
<Link href={`/woche/${stand.wochenNummer}?azubi=${azubi.id}`}>
Die Woche im Einzelnen ansehen →
</Link>
</>
) : (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
Für {azubi.name} ist noch kein Wochenplan angelegt.{' '}
<Link href={`/wochenplan?azubi=${azubi.id}`}>Jetzt erzeugen</Link>.
</p>
)}
</article>
);
})}
</section>
)}
<WartetAufMich ausbilderId={ausbilderId} />
</div>
);
}
// --- Was auf den Ausbilder wartet ------------------------------------------
function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
const meine = ausbildungenVonAusbilder(ausbilderId);
const alle = inhalte();
const azubiIds = meine.map(({ azubi }) => azubi.id);
const abnahmen = wartetAufAbnahme(azubiIds);
const berichte = wartetAufAbzeichnung(azubiIds);
const kontrollen = wartetAufKorrektur(azubiIds);
const gesamt = abnahmen.length + berichte.length + kontrollen.length;
return (
<section className="karte stapel-eng" aria-labelledby="wartet">
<h2 id="wartet" style={{ fontSize: '1rem', marginBottom: 0 }}>
Was auf dich wartet
</h2>
{gesamt === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Nichts offen – keine Abnahme, keine Korrektur, kein Berichtsheft.
</p>
) : (
<ul className="offen-liste">
{kontrollen.map((stand) => {
const kontrolle = alle.kontrolleNach.get(stand.kontrolleId);
const azubi = benutzerNachId(stand.benutzerId);
return (
<li key={stand.id}>
<span>
<Link href={`/kontrollen/${stand.kontrolleId}?azubi=${stand.benutzerId}`}>
<strong>Lernzielkontrolle</strong> {kontrolle?.titel ?? stand.kontrolleId} korrigieren
</Link>
{azubi ? <span className="leise"> · {azubi.name}</span> : null}
</span>
<span className="leise">abgegeben {alsZeitpunktText(stand.abgegebenAm)}</span>
</li>
);
})}
{berichte.map((bericht) => {
const azubi = benutzerNachId(bericht.benutzerId);
return (
<li key={bericht.id}>
<span>
<Link href={`/berichtsheft/${bericht.id}`}>
<strong>Berichtsheft Nr. {bericht.nr}</strong> abzeichnen
</Link>
{azubi ? <span className="leise"> · {azubi.name}</span> : null}
</span>
<span className="leise">eingereicht {alsZeitpunktText(bericht.eingereichtAm)}</span>
</li>
);
})}
{abnahmen.map((stand) => {
const aufgabe = alle.aufgabeNach.get(stand.aufgabeId);
const azubi = benutzerNachId(stand.benutzerId);
return (
<li key={stand.id}>
<span>
<Link href={`/aufgaben/${stand.aufgabeId}?azubi=${stand.benutzerId}`}>
<strong>{stand.aufgabeId}</strong> {aufgabe?.titel ?? 'Aufgabe'}
</Link>
{azubi ? <span className="leise"> · {azubi.name}</span> : null}
</span>
<span className="leise">abgegeben {alsZeitpunktText(stand.abgegebenAm)}</span>
</li>
);
})}
</ul>
)}
</section>
);
}
+13 -2
View File
@@ -6,6 +6,8 @@ import { passwortMangel } from '@/lib/auth/passwort';
import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer';
import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
import { alsDatumText } from '@/lib/zeit';
import { inhalte } from '@/lib/inhalte';
import { planAuffrischen } from '@/lib/repos/wochenplanung';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
@@ -31,7 +33,7 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P
if (benutzerNachEmail(email)) return { fehler: 'Diese E-Mail-Adresse ist schon vergeben.' };
const azubi = await benutzerAnlegen({ name, email, passwort, rolle: 'azubi' });
ausbildungAnlegen({
const neueAusbildung = ausbildungAnlegen({
azubiId: azubi.id,
ausbilderId: ausbilder.id,
beginn,
@@ -39,8 +41,17 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P
ende: datumMuster.test(ende) ? ende : null,
});
// Der Wochenplan entsteht gleich mit – ohne ihn ist die Startseite leer.
const wochen = inhalte().wochen.map((w) => w.nummer);
const eingeplant = planAuffrischen(neueAusbildung.id, beginn, wochen);
revalidatePath('/verwaltung');
return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${alsDatumText(beginn)} eingerichtet.` };
revalidatePath('/');
return {
erfolg:
`Konto für ${name} angelegt und Ausbildung ab ${alsDatumText(beginn)} eingerichtet. ` +
`${eingeplant} ${eingeplant === 1 ? 'Woche' : 'Wochen'} eingeplant.`,
};
}
export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
@@ -0,0 +1,65 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { erfordereBenutzer, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { wochensicht } from '@/lib/planung/sicht';
import { WochenAnsicht } from '../../WochenAnsicht';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ nummer: string }> }): Promise<Metadata> {
const { nummer } = await params;
return { title: `Woche ${nummer}` };
}
/**
* Eine bestimmte Woche. Der Azubi sieht nur seine eigene; der Ausbilder gibt
* über `?azubi=` an, wessen Stand er sehen will – geprüft wird das serverseitig.
*/
export default async function WochenSeite({
params,
searchParams,
}: {
params: Promise<{ nummer: string }>;
searchParams: Promise<{ azubi?: string }>;
}) {
const benutzer = await erfordereBenutzer();
const { nummer } = await params;
const { azubi: azubiParameter } = await searchParams;
const wochenNummer = Number(nummer);
if (!Number.isInteger(wochenNummer) || wochenNummer < 1) notFound();
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (azubiParameter ?? '');
if (!azubiId) notFound();
// Der Kern: Ob dieser Betrachter diesen Azubi sehen darf, entscheidet der
// Server – nicht die Oberfläche und nicht die Adresszeile.
erfordereSichtAufAzubi(benutzer, azubiId);
const ausbildung = ausbildungNachAzubi(azubiId);
if (!ausbildung) notFound();
const sicht = wochensicht(ausbildung.id, azubiId, wochenNummer);
if (!sicht) notFound();
const istAzubi = benutzer.rolle === 'azubi';
const azubi = istAzubi ? null : benutzerNachId(azubiId);
const anhang = istAzubi ? '' : `?azubi=${azubiId}`;
return (
<div className="stapel">
{azubi ? (
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/">Überblick</Link>
{' › '}
<Link href={`/wochenplan?azubi=${azubiId}`}>Wochenplan {azubi.name}</Link>
</nav>
) : null}
<WochenAnsicht sicht={sicht} istAzubi={istAzubi} wochenLink={`/woche/{nr}${anhang}`} />
</div>
);
}
@@ -0,0 +1,146 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { planErzeugen, wochenNotiz, wocheVerlegen, wochenStatusSetzen, type Rueckmeldung } from './aktionen';
export function PlanErzeugenFormular({ azubiId, text }: { azubiId: string; text: string }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(planErzeugen, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="azubiId" value={azubiId} />
<Absenden className="knopf knopf-akzent">{text}</Absenden>
</form>
);
}
export function VerlegenFormular({
azubiId,
wochen,
}: {
azubiId: string;
wochen: { nummer: number; titel: string; geplantVon: string }[];
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(wocheVerlegen, {});
const erste = wochen[0];
return (
<form action={aktion} className="stapel">
<Meldung zustand={zustand} />
<input type="hidden" name="azubiId" value={azubiId} />
<div className="reihe" style={{ alignItems: 'flex-end' }}>
<div style={{ flex: '2 1 240px' }}>
<label htmlFor="verlegen-woche">Woche</label>
<select id="verlegen-woche" name="wochenNummer" defaultValue={erste?.nummer}>
{wochen.map((w) => (
<option key={w.nummer} value={w.nummer}>
Woche {w.nummer} – {w.titel}
</option>
))}
</select>
</div>
<div style={{ flex: '1 1 170px' }}>
<label htmlFor="verlegen-datum">Neuer Beginn</label>
<input id="verlegen-datum" name="neuerMontag" type="date" defaultValue={erste?.geplantVon} required />
</div>
<Absenden className="knopf">Verlegen</Absenden>
</div>
<label className="ankreuzfeld">
<input type="checkbox" name="folgende" value="an" defaultChecked />
<span>
Folgende Wochen mitverschieben
<span className="leise" style={{ display: 'block' }}>
Der Normalfall: Fällt eine Woche aus, rutscht der ganze Rest hinterher. Ohne Haken wird nur diese
eine Woche verlegt – dann können sich Termine überlappen.
</span>
</span>
</label>
<p className="leise" style={{ margin: 0 }}>
Ein beliebiges Datum wird auf den Montag seiner Woche gerundet. Abgeschlossene Wochen bleiben stehen.
</p>
</form>
);
}
export function StatusFormular({
azubiId,
wochenNummer,
status,
}: {
azubiId: string;
wochenNummer: number;
status: 'geplant' | 'laufend' | 'abgeschlossen';
}) {
return (
<form action={wochenStatusSetzen} className="reihe" style={{ gap: '0.35rem' }}>
<input type="hidden" name="azubiId" value={azubiId} />
<input type="hidden" name="wochenNummer" value={wochenNummer} />
<button
type="submit"
name="status"
value="laufend"
className="knopf knopf-still"
disabled={status === 'laufend'}
>
Startet jetzt
</button>
<button
type="submit"
name="status"
value="abgeschlossen"
className="knopf knopf-still"
disabled={status === 'abgeschlossen'}
>
Abschließen
</button>
<button
type="submit"
name="status"
value="geplant"
className="knopf knopf-still"
disabled={status === 'geplant'}
>
Zurücksetzen
</button>
</form>
);
}
export function NotizFormular({
azubiId,
wochenNummer,
notiz,
}: {
azubiId: string;
wochenNummer: number;
notiz: string;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(wochenNotiz, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="azubiId" value={azubiId} />
<input type="hidden" name="wochenNummer" value={wochenNummer} />
<label htmlFor={`notiz-${wochenNummer}`}>Notiz zu dieser Woche (sieht der Azubi)</label>
<textarea
id={`notiz-${wochenNummer}`}
name="notiz"
defaultValue={notiz}
rows={2}
maxLength={500}
placeholder="z. B. Dienstag Berufsschule, Mittwoch Kundeneinsatz Halle 3"
/>
<div>
<Absenden className="knopf knopf-zweit">Notiz speichern</Absenden>
</div>
</form>
);
}
@@ -0,0 +1,158 @@
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { wocheSchieben } from './aktionen';
export type Planeintrag = {
wochenNummer: number;
titel: string;
geplantVon: string;
geplantBis: string;
geplantVonText: string;
geplantBisText: string;
kalenderwoche: string;
tatsaechlichVonText: string | null;
status: 'geplant' | 'laufend' | 'abgeschlossen';
notiz: string | null;
handouts: number;
aufgaben: number;
hatKontrolle: boolean;
};
const statusMarke: Record<Planeintrag['status'], { text: string; ton: string; zeichen: string }> = {
geplant: { text: 'geplant', ton: 'neutral', zeichen: '○' },
laufend: { text: 'läuft', ton: 'warnung', zeichen: '▶' },
abgeschlossen: { text: 'abgeschlossen', ton: 'gut', zeichen: '✓' },
};
/**
* Der Wochenplan zum Umsortieren.
*
* Ziehen und Ablegen mit der Maus, und – genauso wichtig – mit der Tastatur:
* Eintrag anwählen, mit den Pfeiltasten verschieben. Reines Ziehen wäre für
* Tastaturbedienung eine Sackgasse.
*/
export function PlanListe({ eintraege, azubiId }: { eintraege: Planeintrag[]; azubiId: string }) {
const [gezogen, setzeGezogen] = useState<number | null>(null);
const [ueber, setzeUeber] = useState<number | null>(null);
const [laeuft, starte] = useTransition();
const router = useRouter();
function verschiebeAn(wochenNummer: number, neueStelle: number) {
if (neueStelle < 0 || neueStelle >= eintraege.length) return;
const daten = new FormData();
daten.set('azubiId', azubiId);
daten.set('wochenNummer', String(wochenNummer));
daten.set('neueStelle', String(neueStelle));
starte(async () => {
await wocheSchieben(daten);
router.refresh();
});
}
function beiTaste(ereignis: React.KeyboardEvent, wochenNummer: number, stelle: number) {
if (ereignis.key === 'ArrowUp' && (ereignis.altKey || ereignis.ctrlKey)) {
ereignis.preventDefault();
verschiebeAn(wochenNummer, stelle - 1);
}
if (ereignis.key === 'ArrowDown' && (ereignis.altKey || ereignis.ctrlKey)) {
ereignis.preventDefault();
verschiebeAn(wochenNummer, stelle + 1);
}
}
return (
<ol className={`planliste${laeuft ? ' planliste-wartet' : ''}`}>
{eintraege.map((eintrag, stelle) => {
const marke = statusMarke[eintrag.status];
const wirdGezogen = gezogen === eintrag.wochenNummer;
const istZiel = ueber === stelle && gezogen !== null && !wirdGezogen;
return (
<li
key={eintrag.wochenNummer}
className={`planzeile${wirdGezogen ? ' planzeile-gezogen' : ''}${istZiel ? ' planzeile-ziel' : ''}`}
draggable
onDragStart={() => setzeGezogen(eintrag.wochenNummer)}
onDragEnd={() => {
setzeGezogen(null);
setzeUeber(null);
}}
onDragOver={(e) => {
e.preventDefault();
setzeUeber(stelle);
}}
onDrop={(e) => {
e.preventDefault();
if (gezogen !== null && gezogen !== eintrag.wochenNummer) verschiebeAn(gezogen, stelle);
setzeGezogen(null);
setzeUeber(null);
}}
>
<div className="planzeile-griff" aria-hidden="true">
⠿
</div>
<div className="planzeile-inhalt">
<div className="reihe" style={{ gap: '0.5rem' }}>
<span className="wochen-nummer wochen-nummer-klein">Woche {eintrag.wochenNummer}</span>
<span className="marke-status" data-ton={marke.ton}>
{marke.zeichen} {marke.text}
</span>
</div>
<Link href={`/woche/${eintrag.wochenNummer}?azubi=${azubiId}`} className="planzeile-titel">
{eintrag.titel}
</Link>
<p className="leise" style={{ margin: 0 }}>
{eintrag.geplantVonText} – {eintrag.geplantBisText} · {eintrag.kalenderwoche}
{eintrag.tatsaechlichVonText ? ` · tatsächlich ab ${eintrag.tatsaechlichVonText}` : ''}
</p>
<p className="leise" style={{ margin: 0 }}>
{eintrag.handouts} Handouts · {eintrag.aufgaben} Aufgaben
{eintrag.hatKontrolle ? ' · Lernzielkontrolle' : ''}
</p>
{eintrag.notiz ? (
<p className="planzeile-notiz">
<strong>Notiz:</strong> {eintrag.notiz}
</p>
) : null}
</div>
<div className="planzeile-knoepfe">
<button
type="button"
className="knopf knopf-still"
onClick={() => verschiebeAn(eintrag.wochenNummer, stelle - 1)}
onKeyDown={(e) => beiTaste(e, eintrag.wochenNummer, stelle)}
disabled={stelle === 0 || laeuft}
title="Eine Woche früher (auch mit Alt + Pfeil hoch)"
>
<span aria-hidden="true">↑</span>
<span className="nur-lesehilfe">Woche {eintrag.wochenNummer} nach oben</span>
</button>
<button
type="button"
className="knopf knopf-still"
onClick={() => verschiebeAn(eintrag.wochenNummer, stelle + 1)}
onKeyDown={(e) => beiTaste(e, eintrag.wochenNummer, stelle)}
disabled={stelle === eintraege.length - 1 || laeuft}
title="Eine Woche später (auch mit Alt + Pfeil runter)"
>
<span aria-hidden="true">↓</span>
<span className="nur-lesehilfe">Woche {eintrag.wochenNummer} nach unten</span>
</button>
</div>
</li>
);
})}
</ol>
);
}
+112
View File
@@ -0,0 +1,112 @@
'use server';
import { revalidatePath } from 'next/cache';
import { erfordereAusbilder, erfordereBewertungsrecht } from '@/lib/auth/rechte';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import {
notizSetzen,
planAuffrischen,
statusSetzen,
wocheUmsortieren,
wocheVerschieben,
} from '@/lib/repos/wochenplanung';
import { heute } from '@/lib/zeit';
import { wochenStatus, type WochenStatus } from '@/lib/db/schema';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/**
* Alle Aktionen hier prüfen zuerst, ob dieser Ausbilder für diesen Azubi
* zuständig ist. Die Azubikennung kommt aus dem Formular und ist damit
* grundsätzlich manipulierbar – deshalb wird sie nie geglaubt.
*/
async function zustaendigeAusbildung(azubiId: string) {
const ausbilder = await erfordereAusbilder();
erfordereBewertungsrecht(ausbilder, azubiId);
const ausbildung = ausbildungNachAzubi(azubiId);
if (!ausbildung) throw new Error('Zu diesem Konto gibt es keine Ausbildung.');
return ausbildung;
}
/** Alle Seiten auffrischen, auf denen die Planung sichtbar ist. */
function aktualisiere() {
revalidatePath('/wochenplan');
revalidatePath('/');
revalidatePath('/woche/[nummer]', 'page');
}
export async function planErzeugen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const azubiId = String(formular.get('azubiId') ?? '');
const ausbildung = await zustaendigeAusbildung(azubiId);
const nummern = inhalte().wochen.map((w) => w.nummer);
const angelegt = planAuffrischen(ausbildung.id, ausbildung.beginn, nummern);
aktualisiere();
return angelegt === 0
? { erfolg: 'Der Plan war schon vollständig – es gab nichts nachzutragen.' }
: { erfolg: `${angelegt} ${angelegt === 1 ? 'Woche' : 'Wochen'} eingeplant.` };
}
export async function wocheVerlegen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const azubiId = String(formular.get('azubiId') ?? '');
const ausbildung = await zustaendigeAusbildung(azubiId);
const wochenNummer = Number(formular.get('wochenNummer'));
const neuerMontag = String(formular.get('neuerMontag') ?? '').trim();
const folgende = String(formular.get('folgende') ?? '') === 'an';
if (!Number.isInteger(wochenNummer)) return { fehler: 'Wochennummer fehlt.' };
if (!datumMuster.test(neuerMontag)) return { fehler: 'Bitte ein gültiges Datum wählen.' };
const geaendert = wocheVerschieben(ausbildung.id, wochenNummer, neuerMontag, folgende);
aktualisiere();
if (geaendert === 0) return { erfolg: 'Der Termin stand schon so.' };
return {
erfolg: folgende
? `Woche ${wochenNummer} verlegt, ${geaendert - 1} folgende ${geaendert - 1 === 1 ? 'Woche' : 'Wochen'} mitverschoben.`
: `Woche ${wochenNummer} verlegt.`,
};
}
export async function wocheSchieben(formular: FormData): Promise<void> {
const azubiId = String(formular.get('azubiId') ?? '');
const ausbildung = await zustaendigeAusbildung(azubiId);
const wochenNummer = Number(formular.get('wochenNummer'));
const neueStelle = Number(formular.get('neueStelle'));
if (!Number.isInteger(wochenNummer) || !Number.isInteger(neueStelle)) return;
wocheUmsortieren(ausbildung.id, wochenNummer, neueStelle);
aktualisiere();
}
export async function wochenStatusSetzen(formular: FormData): Promise<void> {
const azubiId = String(formular.get('azubiId') ?? '');
const ausbildung = await zustaendigeAusbildung(azubiId);
const wochenNummer = Number(formular.get('wochenNummer'));
const status = String(formular.get('status') ?? '') as WochenStatus;
if (!Number.isInteger(wochenNummer) || !wochenStatus.includes(status)) return;
statusSetzen(ausbildung.id, wochenNummer, status, heute());
aktualisiere();
}
export async function wochenNotiz(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const azubiId = String(formular.get('azubiId') ?? '');
const ausbildung = await zustaendigeAusbildung(azubiId);
const wochenNummer = Number(formular.get('wochenNummer'));
const notiz = String(formular.get('notiz') ?? '');
if (!Number.isInteger(wochenNummer)) return { fehler: 'Wochennummer fehlt.' };
if (notiz.length > 500) return { fehler: 'Die Notiz ist zu lang – höchstens 500 Zeichen.' };
notizSetzen(ausbildung.id, wochenNummer, notiz);
aktualisiere();
return { erfolg: notiz.trim() ? 'Notiz gespeichert.' : 'Notiz entfernt.' };
}

Some files were not shown because too many files have changed in this diff Show More