2 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5.5 3009c53b5c Fassung 1.1.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:58:32 +02:00
Florian WildeandClaude Opus 5.5 44e6c8bb79 Module abschaltbar machen
Merkkarten, Praxisaufgaben, Checklisten, Berichtsheft, Lernzielkontrollen
und Feedbackgespraech lassen sich unter Verwaltung -> Module fuer die ganze
Lern-Bar ab- und einschalten. Wochenplan, Handouts, Glossar, Auswertung und
Verwaltung bleiben immer an.

Gesperrt wird an drei Stellen, nicht im Layout (Next rendert Layouts beim
Seitenwechsel darunter nicht neu): Seiten mit erfordereModul, Routen fuer
PDF, Anhaenge und Lernstand mit modulAn, alle 37 Serveraktionen der Module
mit pruefeModul. Die Anmeldepruefung kommt jeweils zuerst, damit ein
Unangemeldeter nicht am 404 abliest, was abgeschaltet ist.

Ein abgeschaltetes Modul traegt nirgends bei: Wochenplan, Fortschritt,
Was-wartet, Mein Stand, Ergebnisverlauf, Rahmenplanabdeckung, Vorschlaege
fuers Berichtsheft, Karten im Handout, Kurzbefehle im Manifest. Die
Lernstand-Schnittstelle antwortet mit 410, das Geraet verwirft daraufhin
seine Offline-Warteschlange. Abschalten loescht nichts.

Gespeichert in modul_einstellung (Migration 0010); fehlt die Zeile, ist das
Modul an, eine bestehende Datenbank verhaelt sich also wie vorher.

12 Tests fuer die Regeln, Abschnitt A8 im Abnahmelauf, Anleitung Kapitel 9
mit Bildschirmfoto, README, E43.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:58:32 +02:00
54 changed files with 996 additions and 161 deletions
+36
View File
@@ -26,6 +26,42 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
## [Unveröffentlicht] ## [Unveröffentlicht]
## [1.1.0] – 2026-10-05
Module lassen sich abschalten.
### Hinzugefügt
- **Verwaltung → Module.** Merkkarten, Praxisaufgaben, Checklisten,
Berichtsheft, Lernzielkontrollen und Feedbackgespräch lassen sich für die
ganze Lern-Bar ab- und wieder einschalten, sofort und ohne Neustart. Zu jedem
Modul steht dort, was es tut, was sich beim Abschalten ändert und wer es
zuletzt umgeschaltet hat. Wochenplan, Handouts, Glossar, Auswertung und
Verwaltung bleiben immer an
- Ein abgeschaltetes Modul ist **überall weg**: Navigation, Wochenplan und
Wochenfortschritt (eine Woche erreicht ohne das Modul trotzdem 100 %), „Was
auf dich wartet", „Mein Stand", Ergebnisverlauf, Rahmenplanabdeckung,
Vorschläge fürs Berichtsheft, Merkkarten im Handout, Kurzbefehle im
App-Symbol
- Ein abgeschaltetes Modul ist **gesperrt, nicht nur ausgeblendet**: Seiten,
PDF-Ausgaben, Anhänge und alle 37 Serveraktionen der Module prüfen den
Schalter. Die Lernstand-Schnittstelle antwortet mit 410, und Geräte verwerfen
daraufhin offline gesammelte Bewertungen
- **Abschalten löscht nichts.** Nach dem Einschalten ist alles unverändert da
- Die Verwaltung hat zwei Bereiche: Konten und Module
- Migration `0010_module` – eine bestehende Datenbank hat danach alle Module an
und verhält sich wie vorher
- 12 Tests für die Modulregeln, Abschnitt A8 im Abnahmelauf (abschalten,
Sperre für beide Rollen prüfen, einschalten, Daten noch da)
- Anleitung Kapitel 9 „Module ein- und ausschalten" mit Bildschirmfoto,
README-Abschnitt, Entscheidung E43
### Geändert
- Das App-Manifest wird bei jedem Abruf erzeugt statt einmal beim Bau, damit
die Kurzbefehle zum Modulstand passen
- In der Anleitung haben sich die Kapitel ab 9 um eins verschoben
## [1.0.5] – 2026-10-05 ## [1.0.5] – 2026-10-05
Das zweite Vierteljahr beginnt, mit Blick auf Teil 1 der Abschlussprüfung. Das zweite Vierteljahr beginnt, mit Blick auf Teil 1 der Abschlussprüfung.
+38
View File
@@ -493,3 +493,41 @@ oder eine erfolgreiche Anmeldung setzt den Zähler zurück.
- **Gezählt wird vor der Prüfung.** Jeder Versuch gilt sofort als Fehlversuch - **Gezählt wird vor der Prüfung.** Jeder Versuch gilt sofort als Fehlversuch
und wird bei Erfolg zurückgenommen. Zwischen Sperrprüfung und Zählen liegt und wird bei Erfolg zurückgenommen. Zwischen Sperrprüfung und Zählen liegt
so kein `await`; sonst kämen gleichzeitige Anfragen alle an der Sperre vorbei. so kein `await`; sonst kämen gleichzeitige Anfragen alle an der Sperre vorbei.
## E43 · Module schaltbar für die ganze Lern-Bar, gesperrt an drei Stellen, nie gelöscht
Merkkarten, Praxisaufgaben, Checklisten, Berichtsheft, Lernzielkontrollen und
Feedbackgespräch lassen sich unter **Verwaltung → Module** abschalten. Wochenplan,
Handouts, Glossar, Auswertung und Verwaltung bleiben immer an – an ihnen hängt
alles andere, und die Auswertung passt sich ohnehin an.
- **Für die ganze Lern-Bar, nicht je Azubi.** Ein Betrieb startet mit einer
Fachrichtung und entscheidet einmal, womit er arbeitet. Schalter je Azubi
hätten jede Ausbilderansicht je Person unterschiedlich gemacht. Ließe sich
später ergänzen, ohne das Bestehende umzubauen: Der Stand ist schon heute ein
Objekt, das an einer Stelle entsteht (`repos/module`).
- **In der Datenbank, nicht in einer Konfigurationsdatei.** Ein Ausbilder soll
ohne Zugang zum Server umschalten können, und die Änderung soll sofort
wirken, ohne Neustart. Gespeichert wird nur, was je umgeschaltet wurde; fehlt
die Zeile, ist das Modul an. Eine bestehende Datenbank verhält sich nach der
Migration also genau wie vorher.
- **Gesperrt an drei Stellen, nicht im Layout.** Seiten rufen `erfordereModul`
auf (Antwort „nicht gefunden", wie bei fehlender Berechtigung, E21), Routen
für PDF, Anhänge und Lernstand fragen `modulAn`, Serveraktionen rufen
`pruefeModul`. Ein Layout je Modul wäre kürzer gewesen, greift aber nicht
zuverlässig: Next.js rendert ein Layout beim Wechsel zwischen den Seiten
darunter nicht neu.
- **Abschalten löscht nichts.** Beim Berichtsheft ist das zwingend – es ist ein
Nachweis. Für alle anderen Module gilt dasselbe, damit niemand beim
Ausprobieren Daten verliert.
- **Abgeschaltet heißt: trägt nirgends bei.** Kein Eintrag im Wochenplan, kein
Anteil am Wochenfortschritt, kein Nachweis in der Rahmenplanabdeckung, kein
Vorschlag fürs Berichtsheft, kein Kurzbefehl im App-Symbol. Sonst stünden
Inhalte für immer als offen da, die niemand mehr bearbeiten kann.
- **Offline gesammelte Bewertungen werden verworfen**, wenn die Merkkarten aus
sind. Die Lernstand-Schnittstelle antwortet mit 410 statt 404, und das Gerät
leert daraufhin seine Warteschlange, statt sie bei jeder Verbindung erneut zu
schicken.
- **Die Prüfung der Anmeldung kommt zuerst**, die des Moduls danach. Sonst sähe
ein nicht angemeldeter Besucher an „nicht gefunden" statt der Anmeldeseite,
welche Module abgeschaltet sind.
+20 -2
View File
@@ -144,6 +144,21 @@ npm run inhalte:pruefen
--- ---
## Module ein- und ausschalten
Unter **Verwaltung → Module** lassen sich Merkkarten, Praxisaufgaben,
Checklisten, Berichtsheft, Lernzielkontrollen und Feedbackgespräch abschalten –
für die ganze Lern-Bar, sofort und ohne Neustart. Wochenplan, Handouts, Glossar,
Auswertung und Verwaltung bleiben immer an.
Ein abgeschaltetes Modul verschwindet aus Navigation, Wochenplan, Fortschritt,
Auswertung und Rahmenplanabdeckung, und seine Seiten, PDFs und Serveraktionen
sind gesperrt. **Gelöscht wird nichts:** Nach dem Einschalten ist alles wieder
da. Einzelheiten in der [Anleitung](docs/ANLEITUNG.md#9-module-ein--und-ausschalten),
die Begründung in [E43](ENTSCHEIDUNGEN.md).
---
## Datensicherung ## Datensicherung
Gesichert werden **Datenbank und Anhänge**. Die Inhalte stecken im Repository Gesichert werden **Datenbank und Anhänge**. Die Inhalte stecken im Repository
@@ -244,7 +259,7 @@ Protokollzeilen.
```bash ```bash
npm run typecheck # TypeScript npm run typecheck # TypeScript
npm run lint # ESLint npm run lint # ESLint
npm run test # Vitest – Logik, die wehtut (315 Tests) npm run test # Vitest – Logik, die wehtut (341 Tests)
npm run abnahme # der Abnahmelauf, siehe unten npm run abnahme # der Abnahmelauf, siehe unten
npm run abnahme:container # derselbe Weg gegen das Docker-Abbild npm run abnahme:container # derselbe Weg gegen das Docker-Abbild
``` ```
@@ -263,7 +278,10 @@ Datenbank** und spielt den ganzen Weg durch:
4. Der Azubi liest ein Handout, lernt eine Karte, meldet eine Aufgabe zur Abnahme 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 5. Der Ausbilder nimmt sie ab; sie schlägt in der Rahmenplanabdeckung durch
6. Das Wochenblatt wird angelegt und als PDF ausgegeben 6. Das Wochenblatt wird angelegt und als PDF ausgegeben
7. **Gesichert, absichtlich beschädigt, zurückgespielt.** Eine Sicherung, die 7. Berichtsheft und Merkkarten werden über die Verwaltung abgeschaltet: Seite
und PDF sind für beide Rollen gesperrt, nach dem Einschalten ist das
Wochenblatt unverändert da
8. **Gesichert, absichtlich beschädigt, zurückgespielt.** Eine Sicherung, die
nie zurückgespielt wurde, ist keine Sicherung nie zurückgespielt wurde, ist keine Sicherung
Das Protokoll landet in [`docs/ABNAHME.md`](docs/ABNAHME.md). Das Protokoll landet in [`docs/ABNAHME.md`](docs/ABNAHME.md).
+11
View File
@@ -117,6 +117,17 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
- [x] Abnahmekriterien einzeln durchgespielt und protokolliert - [x] Abnahmekriterien einzeln durchgespielt und protokolliert
(`docs/ABNAHME.md`) (`docs/ABNAHME.md`)
## 1.1 – Module
- [x] **Module abschaltbar** unter Verwaltung → Module
- [x] Merkkarten, Praxisaufgaben, Checklisten, Berichtsheft,
Lernzielkontrollen, Feedbackgespräch; das Gerüst bleibt immer an
- [x] Gesperrt in Seiten, Routen und Serveraktionen, nicht nur ausgeblendet
- [x] Wochenplan, Fortschritt, Auswertung, Rahmenplanabdeckung, Vorschläge
und App-Kurzbefehle richten sich nach dem Stand
- [x] Abschalten löscht nichts
- [x] Im Abnahmelauf geprüft (A8)
--- ---
## Danach – bewusst zurückgestellt ## Danach – bewusst zurückgestellt
+16 -6
View File
@@ -1,6 +1,6 @@
# Abnahmeprotokoll # Abnahmeprotokoll
Erzeugt am 2026-10-04 16:24 Uhr Erzeugt am 2026-10-05 14:48 Uhr
Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenbank**. Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenbank**.
@@ -54,15 +54,25 @@ Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenba
- ✓ Der Azubi legt das Wochenblatt der laufenden Woche an - ✓ Der Azubi legt das Wochenblatt der laufenden Woche an
- ✓ Ein Wochenblatt lässt sich öffnen - ✓ Ein Wochenblatt lässt sich öffnen
- ✓ Das Wochenblatt kommt als PDF heraus — HTTP 200, 2649 Bytes - ✓ Das Wochenblatt kommt als PDF heraus — HTTP 200, 2646 Bytes
## A8 · Sicherung zurückspielen ## A8 · Module abschalten
- ✓ Die Sicherung läuft durch — lernbar-20261004-182433.tar.gz - ✓ Der Ausbilder schaltet das Berichtsheft ab
- ✓ Das Berichtsheft verschwindet aus der Navigation
- ✓ Die Seite ist für beide Rollen gesperrt, nicht nur ausgeblendet — Azubi HTTP 404, Ausbilder HTTP 404
- ✓ Auch das PDF ist gesperrt — HTTP 404
- ✓ Ohne Merkkarten zeigt das Handout keine Karten
- ✓ Ein Gerät mit gesammelten Bewertungen erfährt, dass es sie verwerfen soll — HTTP 410
- ✓ Nach dem Einschalten ist das Wochenblatt unverändert wieder da — HTTP 200
## A9 · Sicherung zurückspielen
- ✓ Die Sicherung läuft durch — lernbar-20261005-164818.tar.gz
- ✓ Zum Beweis wird der Datenbestand beschädigt — 2 → 1 Konten - ✓ Zum Beweis wird der Datenbestand beschädigt — 2 → 1 Konten
- ✓ Die Rückspielung läuft durch — ohne Fehler - ✓ Die Rückspielung läuft durch — ohne Fehler
- ✓ Der gelöschte Azubi ist wieder da — Tim Vogt - ✓ Der gelöschte Azubi ist wieder da — Tim Vogt
- ✓ Auch der Lernverlauf kam mit zurück — 1 Einträge - ✓ Auch der Lernverlauf kam mit zurück — 1 Einträge
- ✓ Der überschriebene Stand wurde zur Seite gelegt, nicht gelöscht — verdraengt-20261004-182436 - ✓ Der überschriebene Stand wurde zur Seite gelegt, nicht gelöscht — verdraengt-20261005-164821
**36 von 36 Prüfungen erfüllt.** **43 von 43 Prüfungen erfüllt.**
+64 -24
View File
@@ -22,24 +22,25 @@ gemalt, sondern gemessen.
6. [Eine Lernzielkontrolle korrigieren](#6-eine-lernzielkontrolle-korrigieren) 6. [Eine Lernzielkontrolle korrigieren](#6-eine-lernzielkontrolle-korrigieren)
7. [Die Rahmenplanabdeckung lesen](#7-die-rahmenplanabdeckung-lesen) 7. [Die Rahmenplanabdeckung lesen](#7-die-rahmenplanabdeckung-lesen)
8. [Ein Feedbackgespräch führen](#8-ein-feedbackgespräch-führen) 8. [Ein Feedbackgespräch führen](#8-ein-feedbackgespräch-führen)
9. [Module ein- und ausschalten](#9-module-ein--und-ausschalten)
**Für den Azubi** **Für den Azubi**
9. [Die Woche](#9-die-woche) 10. [Die Woche](#10-die-woche)
10. [Handouts lesen und suchen](#10-handouts-lesen-und-suchen) 11. [Handouts lesen und suchen](#11-handouts-lesen-und-suchen)
11. [Merkkarten lernen](#11-merkkarten-lernen) 12. [Merkkarten lernen](#12-merkkarten-lernen)
12. [Eine Praxisaufgabe abgeben](#12-eine-praxisaufgabe-abgeben) 13. [Eine Praxisaufgabe abgeben](#13-eine-praxisaufgabe-abgeben)
13. [Eine Checkliste abarbeiten](#13-eine-checkliste-abarbeiten) 14. [Eine Checkliste abarbeiten](#14-eine-checkliste-abarbeiten)
14. [Das Berichtsheft führen](#14-das-berichtsheft-führen) 15. [Das Berichtsheft führen](#15-das-berichtsheft-führen)
15. [Eine Lernzielkontrolle schreiben](#15-eine-lernzielkontrolle-schreiben) 16. [Eine Lernzielkontrolle schreiben](#16-eine-lernzielkontrolle-schreiben)
**Überall** **Überall**
16. [Auf dem Handy](#16-auf-dem-handy) 17. [Auf dem Handy](#17-auf-dem-handy)
17. [Ohne Netz](#17-ohne-netz) 18. [Ohne Netz](#18-ohne-netz)
18. [Hell und dunkel](#18-hell-und-dunkel) 19. [Hell und dunkel](#19-hell-und-dunkel)
19. [Drucken](#19-drucken) 20. [Drucken](#20-drucken)
20. [Wenn etwas nicht geht](#20-wenn-etwas-nicht-geht) 21. [Wenn etwas nicht geht](#21-wenn-etwas-nicht-geht)
--- ---
@@ -218,11 +219,49 @@ Unterschriftenfeldern. Abweichungen sind auch dort markiert.
> anpassen. Ein bereits geführtes Gespräch behält seine Fassung, auch wenn du > anpassen. Ein bereits geführtes Gespräch behält seine Fassung, auch wenn du
> die Vorlage später änderst. > die Vorlage später änderst.
### 9. Module ein- und ausschalten
**Verwaltung → Module**
![Verwaltung, Bereich Module, das Berichtsheft ist abgeschaltet](bilder/25-module.png)
Nicht jeder Betrieb braucht alles. Führt ihr das Berichtsheft zum Beispiel im
Portal der Kammer, schaltest du es hier ab – für die ganze Lern-Bar, für alle
Azubis und Ausbilder zugleich.
| Modul | Abschaltbar |
|---|---|
| Merkkarten, Praxisaufgaben, Checklisten, Berichtsheft, Lernzielkontrollen, Feedbackgespräch | ja |
| Wochenplan, Handouts, Glossar, Auswertung, Verwaltung | nein – das Gerüst |
Was beim Abschalten passiert:
- **Das Modul verschwindet** aus der Navigation, aus dem Wochenplan, aus „Was
auf dich wartet" und aus der Auswertung. Zu jedem Modul steht auf der Seite,
was sich genau ändert.
- **Seiten und PDFs sind gesperrt**, nicht nur ausgeblendet. Wer sich die
Adresse gemerkt hat, bekommt „Diese Seite gibt es nicht".
- **Der Wochenfortschritt** zählt das Modul nicht mehr mit. Eine Woche ohne
Merkkarten erreicht also trotzdem 100 %.
- **Die Rahmenplanabdeckung** zählt abgeschaltete Aufgaben und Checklisten
nicht mehr. Bleiben nur Handouts, gilt eine Position mit zwei gelesenen
Handouts als vermittelt.
- **Nichts wird gelöscht.** Was im Modul schon geschrieben wurde – Berichtshefte,
Bewertungen, Abnahmen –, bleibt gespeichert und ist nach dem Einschalten
unverändert wieder da.
Unter jedem Modul steht, wer es wann zuletzt umgeschaltet hat.
> **Berichtsheft abschalten heißt nicht, kein Berichtsheft zu führen.** Der
> Ausbildungsnachweis ist Pflicht und Voraussetzung für die Zulassung zur
> Abschlussprüfung. Er wird dann eben woanders geführt. In den Wochenplänen
> steht freitags weiterhin „Berichtsheft schreiben" – das gilt auch dann.
--- ---
## Für den Azubi ## Für den Azubi
### 9. Die Woche ### 10. Die Woche
![Woche des Azubis](bilder/10-azubi-woche.png) ![Woche des Azubis](bilder/10-azubi-woche.png)
@@ -232,7 +271,7 @@ Frage lautet „Was mache ich jetzt?", nicht „Wie ist meine Gesamtlage?".
--- ---
### 10. Handouts lesen und suchen ### 11. Handouts lesen und suchen
![Handouts](bilder/11-handouts-liste.png) ![Handouts](bilder/11-handouts-liste.png)
@@ -254,7 +293,7 @@ mit Querverweisen.
--- ---
### 11. Merkkarten lernen ### 12. Merkkarten lernen
![Merkkarten](bilder/14-merkkarten.png) ![Merkkarten](bilder/14-merkkarten.png)
@@ -273,7 +312,7 @@ nachzuholen.
--- ---
### 12. Eine Praxisaufgabe abgeben ### 13. Eine Praxisaufgabe abgeben
![Aufgaben](bilder/16-aufgaben-liste.png) ![Aufgaben](bilder/16-aufgaben-liste.png)
@@ -287,7 +326,7 @@ Dann gibst du ab. Der Ausbilder nimmt ab oder gibt zurück, mit Bemerkung.
--- ---
### 13. Eine Checkliste abarbeiten ### 14. Eine Checkliste abarbeiten
![Checklisten](bilder/18-checklisten.png) ![Checklisten](bilder/18-checklisten.png)
@@ -304,7 +343,7 @@ begonnen hast. Am Ende gibt es ein PDF – ein Beispiel liegt unter
--- ---
### 14. Das Berichtsheft führen ### 15. Das Berichtsheft führen
![Berichtsheft](bilder/21-berichtsheft.png) ![Berichtsheft](bilder/21-berichtsheft.png)
@@ -323,7 +362,7 @@ Nach dem Abzeichnen ist das Blatt unveränderlich.
--- ---
### 15. Eine Lernzielkontrolle schreiben ### 16. Eine Lernzielkontrolle schreiben
Der Ausbilder gibt die Kontrolle frei, du siehst sie unter *Kontrollen*. Du Der Ausbilder gibt die Kontrolle frei, du siehst sie unter *Kontrollen*. Du
schreibst deine Antworten und sendest ab. schreibst deine Antworten und sendest ab.
@@ -336,7 +375,7 @@ Kontrolle, deren Lösung vorher abrufbar wäre, misst nichts.
## Überall ## Überall
### 16. Auf dem Handy ### 17. Auf dem Handy
Die Anwendung ist für das Handy gebaut, nicht dafür angepasst. Der Azubi hat es Die Anwendung ist für das Handy gebaut, nicht dafür angepasst. Der Azubi hat es
in der Tasche, nicht den Laptop. in der Tasche, nicht den Laptop.
@@ -355,7 +394,7 @@ Danach startet sie ohne Adresszeile und mit eigenem Symbol.
--- ---
### 17. Ohne Netz ### 18. Ohne Netz
Im Serverraum, im Keller, im Zug gibt es kein Netz. Deshalb: Im Serverraum, im Keller, im Zug gibt es kein Netz. Deshalb:
@@ -376,7 +415,7 @@ weggeräumt.
--- ---
### 18. Hell und dunkel ### 19. Hell und dunkel
![Dunkle Fassung](bilder/20-dunkel.png) ![Dunkle Fassung](bilder/20-dunkel.png)
@@ -386,7 +425,7 @@ hast – sonst käme schwarzes Papier aus dem Drucker.
--- ---
### 19. Drucken ### 20. Drucken
Für die Dokumente, die eine Unterschrift tragen, gibt es **PDFs**: Für die Dokumente, die eine Unterschrift tragen, gibt es **PDFs**:
Wochenblatt, Checklisten-Durchlauf, Feedbackgespräch. Wochenblatt, Checklisten-Durchlauf, Feedbackgespräch.
@@ -397,11 +436,12 @@ mitten durchgeschnitten, aufklappbare Abschnitte drucken aufgeklappt.
--- ---
### 20. Wenn etwas nicht geht ### 21. Wenn etwas nicht geht
| Beobachtung | Woran es liegt | | Beobachtung | Woran es liegt |
|---|---| |---|---|
| „Zu viele Fehlversuche" bei der Anmeldung | Nach fünf falschen Passwörtern wird die Adresse für eine Minute gesperrt, bei weiteren Fehlversuchen länger, höchstens 15 Minuten. Abwarten – oder Passwort vom Ausbilder neu setzen lassen. Hat sich das einzige Ausbilderkonto ausgesperrt: `docker compose restart lernbar` setzt die Sperre zurück. | | „Zu viele Fehlversuche" bei der Anmeldung | Nach fünf falschen Passwörtern wird die Adresse für eine Minute gesperrt, bei weiteren Fehlversuchen länger, höchstens 15 Minuten. Abwarten – oder Passwort vom Ausbilder neu setzen lassen. Hat sich das einzige Ausbilderkonto ausgesperrt: `docker compose restart lernbar` setzt die Sperre zurück. |
| Ein Bereich fehlt in der Navigation, oder seine Seite meldet „Diese Seite gibt es nicht" | Das Modul ist abgeschaltet. Ein Ausbilder schaltet es unter **Verwaltung → Module** wieder ein; die Daten sind noch da. |
| „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". | | „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. | | 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 Anhang lässt sich nicht hochladen | Über 25 MB. Bei einem Reverse Proxy zusätzlich `client_max_body_size 30m` setzen (siehe README). |
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "lernbar", "name": "lernbar",
"version": "1.0.5", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lernbar", "name": "lernbar",
"version": "1.0.5", "version": "1.1.0",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lernbar", "name": "lernbar",
"version": "1.0.5", "version": "1.1.0",
"private": true, "private": true,
"description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration", "description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration",
"engines": { "engines": {
+54 -2
View File
@@ -404,6 +404,7 @@ try {
); );
const blatt = await z.$('.blattzeile-link'); const blatt = await z.$('.blattzeile-link');
let blattAdresse = null;
if (blatt) { if (blatt) {
await blatt.click(); await blatt.click();
// Auf die Adresse warten, nicht auf eine Überschrift: Die steht auch auf // Auf die Adresse warten, nicht auf eine Überschrift: Die steht auch auf
@@ -411,6 +412,7 @@ try {
// die Anfrage landet auf der Sammelroute statt auf dem einzelnen Blatt. // die Anfrage landet auf der Sammelroute statt auf dem einzelnen Blatt.
await z.waitForURL(/\/berichtsheft\/[0-9a-f-]{36}$/, { timeout: 30000 }); await z.waitForURL(/\/berichtsheft\/[0-9a-f-]{36}$/, { timeout: 30000 });
pruefe(true, 'Ein Wochenblatt lässt sich öffnen'); pruefe(true, 'Ein Wochenblatt lässt sich öffnen');
blattAdresse = z.url();
const pdf = await azubi.request.get(`${z.url()}/pdf`); const pdf = await azubi.request.get(`${z.url()}/pdf`);
const puffer = await pdf.body(); const puffer = await pdf.body();
@@ -425,9 +427,59 @@ try {
pruefe(false, 'Ein Wochenblatt lässt sich öffnen', 'keine Blattzeile gefunden'); pruefe(false, 'Ein Wochenblatt lässt sich öffnen', 'keine Blattzeile gefunden');
} }
// --- A8 Sicherung und Rückspielung --------------------------------------- // --- A8 Module abschalten -------------------------------------------------
abschnitt('A8 · Sicherung zurückspielen'); abschnitt('A8 · Module abschalten');
// Über die Oberfläche, wie im Betrieb: Verwaltung → Module.
const umschalten = async (modul) => {
await a.goto(`${BASIS}/verwaltung/module`);
await a.click(`form:has(input[name="modul"][value="${modul}"]) button[type=submit]`);
await a.waitForLoadState('networkidle');
};
const zustandIn = async (modul) => {
await a.goto(`${BASIS}/verwaltung/module`);
// Die Statusmarke steht direkt neben der Überschrift des Moduls.
return (await a.locator(`#modul-${modul} + .marke-status`).innerText()).trim();
};
await umschalten('berichtsheft');
await umschalten('karten');
pruefe((await zustandIn('berichtsheft')).includes('aus'), 'Der Ausbilder schaltet das Berichtsheft ab');
await z.goto(`${BASIS}/`);
pruefe((await z.locator('nav a[href="/berichtsheft"]').count()) === 0, 'Das Berichtsheft verschwindet aus der Navigation');
const seiteAus = await azubi.request.get(`${BASIS}/berichtsheft`);
const ausbilderSeiteAus = await ausbilder.request.get(`${BASIS}/berichtsheft`);
pruefe(
seiteAus.status() === 404 && ausbilderSeiteAus.status() === 404,
'Die Seite ist für beide Rollen gesperrt, nicht nur ausgeblendet',
`Azubi HTTP ${seiteAus.status()}, Ausbilder HTTP ${ausbilderSeiteAus.status()}`,
);
if (blattAdresse) {
const pdfAus = await azubi.request.get(`${blattAdresse}/pdf`);
pruefe(pdfAus.status() === 404, 'Auch das PDF ist gesperrt', `HTTP ${pdfAus.status()}`);
}
await z.goto(`${BASIS}/handouts/N3`);
pruefe(
(await z.locator('text=Merkkarten zu diesem Handout').count()) === 0,
'Ohne Merkkarten zeigt das Handout keine Karten',
);
const lernstandAus = await azubi.request.post(`${BASIS}/api/lernstand`, { data: { eintraege: [] } });
pruefe(lernstandAus.status() === 410, 'Ein Gerät mit gesammelten Bewertungen erfährt, dass es sie verwerfen soll', `HTTP ${lernstandAus.status()}`);
await umschalten('berichtsheft');
await umschalten('karten');
if (blattAdresse) {
const wieder = await azubi.request.get(blattAdresse);
pruefe(wieder.status() === 200, 'Nach dem Einschalten ist das Wochenblatt unverändert wieder da', `HTTP ${wieder.status()}`);
}
// --- A9 Sicherung und Rückspielung ---------------------------------------
abschnitt('A9 · Sicherung zurückspielen');
// Pfade für bash mit Schrägstrichen: Ein Backslash ist dort ein Fluchtzeichen. // Pfade für bash mit Schrägstrichen: Ein Backslash ist dort ein Fluchtzeichen.
const alsPosix = (pfad) => pfad.split(path.sep).join('/'); const alsPosix = (pfad) => pfad.split(path.sep).join('/');
+12
View File
@@ -74,6 +74,18 @@ async function main() {
await seite.waitForSelector('#neu-name'); await seite.waitForSelector('#neu-name');
await bild(seite, '03-verwaltung'); await bild(seite, '03-verwaltung');
// Module: für das Bild ein Modul abschalten, danach den alten Stand herstellen.
const umschalten = async (modul: string) => {
await seite.goto(`${BASIS}/verwaltung/module`);
await seite.click(`form:has(input[name="modul"][value="${modul}"]) button[type=submit]`);
await seite.waitForLoadState('networkidle');
};
await umschalten('berichtsheft');
await seite.goto(`${BASIS}/verwaltung/module`);
await seite.waitForSelector('#modul-karten');
await bild(seite, '25-module');
await umschalten('berichtsheft');
await seite.goto(`${BASIS}/wochenplan`); await seite.goto(`${BASIS}/wochenplan`);
await seite.waitForSelector('h1'); await seite.waitForSelector('h1');
await bild(seite, '04-wochenplan'); await bild(seite, '04-wochenplan');
+56 -50
View File
@@ -155,58 +155,64 @@ export function WochenAnsicht({
)} )}
</section> </section>
<section className="karte" aria-labelledby="woche-aufgaben"> {sicht.module.aufgaben ? (
<h2 id="woche-aufgaben" style={{ fontSize: '1rem' }}> <section className="karte" aria-labelledby="woche-aufgaben">
Praxisaufgaben <h2 id="woche-aufgaben" style={{ fontSize: '1rem' }}>
</h2> Praxisaufgaben
{sicht.aufgaben.length === 0 ? ( </h2>
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p> {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) => ( <ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
<li key={aufgabe.id}> {sicht.aufgaben.map((aufgabe) => (
<strong>{aufgabe.id}</strong> {aufgabe.titel} <li key={aufgabe.id}>
<br /> <strong>{aufgabe.id}</strong> {aufgabe.titel}
<span className="leise">{aufgabe.zeitrahmen}</span> <br />
</li> <span className="leise">{aufgabe.zeitrahmen}</span>
))} </li>
</ul> ))}
)} </ul>
</section> )}
</section>
) : null}
<section className="karte" aria-labelledby="woche-checklisten"> {sicht.module.checklisten ? (
<h2 id="woche-checklisten" style={{ fontSize: '1rem' }}> <section className="karte" aria-labelledby="woche-checklisten">
Checklisten <h2 id="woche-checklisten" style={{ fontSize: '1rem' }}>
</h2> Checklisten
{sicht.checklisten.length === 0 ? ( </h2>
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p> {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) => ( <ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
<li key={liste.id}>{liste.titel}</li> {sicht.checklisten.map((liste) => (
))} <li key={liste.id}>{liste.titel}</li>
</ul> ))}
)} </ul>
</section> )}
</section>
) : null}
<section className="karte" aria-labelledby="woche-kontrolle"> {sicht.module.kontrollen ? (
<h2 id="woche-kontrolle" style={{ fontSize: '1rem' }}> <section className="karte" aria-labelledby="woche-kontrolle">
Lernzielkontrolle <h2 id="woche-kontrolle" style={{ fontSize: '1rem' }}>
</h2> Lernzielkontrolle
{sicht.kontrolle ? ( </h2>
<p style={{ marginBottom: 0 }}> {sicht.kontrolle ? (
{sicht.kontrolle.titel} <p style={{ marginBottom: 0 }}>
<br /> {sicht.kontrolle.titel}
<span className="leise"> <br />
{sicht.kontrolle.fragen.length} Fragen ·{' '} <span className="leise">
{sicht.kontrolle.fragen.reduce((summe, f) => summe + f.punkte, 0)} Punkte {sicht.kontrolle.fragen.length} Fragen ·{' '}
{sicht.kontrolle.dauer_minuten ? ` · ${sicht.kontrolle.dauer_minuten} Minuten` : ''} {sicht.kontrolle.fragen.reduce((summe, f) => summe + f.punkte, 0)} Punkte
</span> {sicht.kontrolle.dauer_minuten ? ` · ${sicht.kontrolle.dauer_minuten} Minuten` : ''}
</p> </span>
) : ( </p>
<p className="leise" style={{ marginBottom: 0 }}>Diese Woche keine.</p> ) : (
)} <p className="leise" style={{ marginBottom: 0 }}>Diese Woche keine.</p>
</section> )}
</section>
) : null}
</div> </div>
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Andere Wochen"> <nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Andere Wochen">
@@ -12,6 +12,7 @@ import { alsZeitpunktText } from '@/lib/zeit';
import { StatusMarke } from '../StatusMarke'; import { StatusMarke } from '../StatusMarke';
import { AnhangFormular, BewertungsFormular, NotizFormular, StatusWaehlen } from './Formulare'; import { AnhangFormular, BewertungsFormular, NotizFormular, StatusWaehlen } from './Formulare';
import { anhangEntfernen, aufgabeAbnehmen, aufgabeZurueckgeben } from '../aktionen'; import { anhangEntfernen, aufgabeAbnehmen, aufgabeZurueckgeben } from '../aktionen';
import { erfordereModul } from '@/lib/module/pruefen';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -29,6 +30,7 @@ export default async function AufgabenSeite({
searchParams: Promise<{ azubi?: string }>; searchParams: Promise<{ azubi?: string }>;
}) { }) {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
erfordereModul('aufgaben');
const { id } = await params; const { id } = await params;
const { azubi: azubiParameter } = await searchParams; const { azubi: azubiParameter } = await searchParams;
@@ -13,6 +13,7 @@ import {
zurueckgeben, zurueckgeben,
} from '@/lib/repos/aufgaben'; } from '@/lib/repos/aufgaben';
import { aufgabenStatus, type AufgabenStatus } from '@/lib/db/schema'; import { aufgabenStatus, type AufgabenStatus } from '@/lib/db/schema';
import { pruefeModul } from '@/lib/module/pruefen';
export type Rueckmeldung = { fehler?: string; erfolg?: string }; export type Rueckmeldung = { fehler?: string; erfolg?: string };
@@ -30,6 +31,7 @@ function aufgabeGibtEs(aufgabeId: string): boolean {
export async function eigenenStatusSetzen(formular: FormData): Promise<void> { export async function eigenenStatusSetzen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('aufgaben');
if (benutzer.rolle !== 'azubi') return; if (benutzer.rolle !== 'azubi') return;
const aufgabeId = String(formular.get('aufgabeId') ?? ''); const aufgabeId = String(formular.get('aufgabeId') ?? '');
@@ -46,6 +48,7 @@ export async function eigenenStatusSetzen(formular: FormData): Promise<void> {
export async function eigeneNotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function eigeneNotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('aufgaben');
if (benutzer.rolle !== 'azubi') return { fehler: 'Nur der Azubi schreibt seine eigenen Notizen.' }; if (benutzer.rolle !== 'azubi') return { fehler: 'Nur der Azubi schreibt seine eigenen Notizen.' };
const aufgabeId = String(formular.get('aufgabeId') ?? ''); const aufgabeId = String(formular.get('aufgabeId') ?? '');
@@ -61,6 +64,7 @@ export async function eigeneNotizSpeichern(_vorher: Rueckmeldung, formular: Form
export async function anhangHochladen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function anhangHochladen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('aufgaben');
if (benutzer.rolle !== 'azubi') return { fehler: 'Anhänge lädt der Azubi hoch.' }; if (benutzer.rolle !== 'azubi') return { fehler: 'Anhänge lädt der Azubi hoch.' };
const aufgabeId = String(formular.get('aufgabeId') ?? ''); const aufgabeId = String(formular.get('aufgabeId') ?? '');
@@ -107,6 +111,7 @@ export async function anhangHochladen(_vorher: Rueckmeldung, formular: FormData)
export async function anhangEntfernen(formular: FormData): Promise<void> { export async function anhangEntfernen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('aufgaben');
const anhangId = String(formular.get('anhangId') ?? ''); const anhangId = String(formular.get('anhangId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? ''); const aufgabeId = String(formular.get('aufgabeId') ?? '');
@@ -126,6 +131,7 @@ export async function anhangEntfernen(formular: FormData): Promise<void> {
export async function bewerten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function bewerten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer(); const ausbilder = await erfordereBenutzer();
pruefeModul('aufgaben');
const azubiId = String(formular.get('azubiId') ?? ''); const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? ''); const aufgabeId = String(formular.get('aufgabeId') ?? '');
@@ -157,6 +163,7 @@ export async function bewerten(_vorher: Rueckmeldung, formular: FormData): Promi
export async function aufgabeAbnehmen(formular: FormData): Promise<void> { export async function aufgabeAbnehmen(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer(); const ausbilder = await erfordereBenutzer();
pruefeModul('aufgaben');
const azubiId = String(formular.get('azubiId') ?? ''); const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? ''); const aufgabeId = String(formular.get('aufgabeId') ?? '');
@@ -169,6 +176,7 @@ export async function aufgabeAbnehmen(formular: FormData): Promise<void> {
export async function aufgabeZurueckgeben(formular: FormData): Promise<void> { export async function aufgabeZurueckgeben(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer(); const ausbilder = await erfordereBenutzer();
pruefeModul('aufgaben');
const azubiId = String(formular.get('azubiId') ?? ''); const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? ''); const aufgabeId = String(formular.get('aufgabeId') ?? '');
@@ -182,5 +190,6 @@ export async function aufgabeZurueckgeben(formular: FormData): Promise<void> {
/** Wird von der Aufgabenseite gebraucht, um fremde Daten überhaupt lesen zu dürfen. */ /** Wird von der Aufgabenseite gebraucht, um fremde Daten überhaupt lesen zu dürfen. */
export async function pruefeLesezugriff(azubiId: string): Promise<void> { export async function pruefeLesezugriff(azubiId: string): Promise<void> {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('aufgaben');
erfordereZugriffAufAzubi(benutzer, azubiId); erfordereZugriffAufAzubi(benutzer, azubiId);
} }
+2
View File
@@ -6,12 +6,14 @@ import { inhalte } from '@/lib/inhalte';
import { staendeVon } from '@/lib/repos/aufgaben'; import { staendeVon } from '@/lib/repos/aufgaben';
import { alsZeitpunktText } from '@/lib/zeit'; import { alsZeitpunktText } from '@/lib/zeit';
import { StatusMarke } from './StatusMarke'; import { StatusMarke } from './StatusMarke';
import { erfordereModul } from '@/lib/module/pruefen';
export const metadata: Metadata = { title: 'Praxisaufgaben' }; export const metadata: Metadata = { title: 'Praxisaufgaben' };
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export default async function AufgabenListe() { export default async function AufgabenListe() {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
erfordereModul('aufgaben');
const alle = inhalte(); const alle = inhalte();
if (benutzer.rolle === 'ausbilder') { if (benutzer.rolle === 'ausbilder') {
+47 -34
View File
@@ -103,38 +103,44 @@ function AzubiAuswertung({ azubiId, name }: { azubiId: string; name: string }) {
anteil={anteil(stand.handouts.erledigt, stand.handouts.gesamt)} anteil={anteil(stand.handouts.erledigt, stand.handouts.gesamt)}
fuss={`${stand.handouts.gesamt - stand.handouts.erledigt} noch offen`} fuss={`${stand.handouts.gesamt - stand.handouts.erledigt} noch offen`}
/> />
<Kachel {stand.module.karten ? (
titel="Merkkarten, die sitzen" <Kachel
wert={`${stand.karten.erledigt} von ${stand.karten.gesamt}`} titel="Merkkarten, die sitzen"
anteil={anteil(stand.karten.erledigt, stand.karten.gesamt)} wert={`${stand.karten.erledigt} von ${stand.karten.gesamt}`}
fuss={stand.karten.faellig > 0 ? `${stand.karten.faellig} heute fällig` : 'heute nichts fällig'} 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" ) : null}
wert={`${stand.aufgaben.erledigt} von ${stand.aufgaben.gesamt}`} {stand.module.aufgaben ? (
anteil={anteil(stand.aufgaben.erledigt, stand.aufgaben.gesamt)} <Kachel
fuss={ titel="Praxisaufgaben abgenommen"
stand.aufgaben.zurAbnahme > 0 wert={`${stand.aufgaben.erledigt} von ${stand.aufgaben.gesamt}`}
? `${stand.aufgaben.zurAbnahme} wartet auf Abnahme` anteil={anteil(stand.aufgaben.erledigt, stand.aufgaben.gesamt)}
: `${stand.aufgaben.offen} noch offen` fuss={
} stand.aufgaben.zurAbnahme > 0
/> ? `${stand.aufgaben.zurAbnahme} wartet auf Abnahme`
<Kachel : `${stand.aufgaben.offen} noch offen`
titel="Berichtsheft abgezeichnet" }
wert={`${stand.berichtsheft.abgezeichnet} von ${stand.berichtsheft.gesamt}`} />
anteil={anteil(stand.berichtsheft.abgezeichnet, stand.berichtsheft.gesamt)} ) : null}
fuss={ {stand.module.berichtsheft ? (
stand.berichtsheft.fehlendeWochen > 0 <Kachel
? `${stand.berichtsheft.fehlendeWochen} Wochen ohne Blatt` titel="Berichtsheft abgezeichnet"
: stand.berichtsheft.eingereicht > 0 wert={`${stand.berichtsheft.abgezeichnet} von ${stand.berichtsheft.gesamt}`}
? `${stand.berichtsheft.eingereicht} beim Ausbilder` anteil={anteil(stand.berichtsheft.abgezeichnet, stand.berichtsheft.gesamt)}
: 'nichts offen' fuss={
} stand.berichtsheft.fehlendeWochen > 0
ton={stand.berichtsheft.fehlendeWochen > 0 ? 'fehler' : undefined} ? `${stand.berichtsheft.fehlendeWochen} Wochen ohne Blatt`
/> : stand.berichtsheft.eingereicht > 0
? `${stand.berichtsheft.eingereicht} beim Ausbilder`
: 'nichts offen'
}
ton={stand.berichtsheft.fehlendeWochen > 0 ? 'fehler' : undefined}
/>
) : null}
</div> </div>
{stand.berichtsheft.fehlendeWochen > 0 ? ( {stand.module.berichtsheft && stand.berichtsheft.fehlendeWochen > 0 ? (
<p className="hinweis hinweis-warnung"> <p className="hinweis hinweis-warnung">
<strong> <strong>
{stand.berichtsheft.fehlendeWochen}{' '} {stand.berichtsheft.fehlendeWochen}{' '}
@@ -145,7 +151,7 @@ function AzubiAuswertung({ azubiId, name }: { azubiId: string; name: string }) {
</p> </p>
) : null} ) : null}
<ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} /> {stand.module.kontrollen ? <ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} /> : null}
</div> </div>
); );
} }
@@ -180,9 +186,14 @@ function Kachel({
// --- Rahmenplanabdeckung --------------------------------------------------- // --- Rahmenplanabdeckung ---------------------------------------------------
function Rahmenplan({ azubiId }: { azubiId: string }) { function Rahmenplan({ azubiId }: { azubiId: string }) {
const { positionen, gruppennamen } = rahmenplanabdeckung(azubiId); const { positionen, gruppennamen, module: eingeschaltet } = rahmenplanabdeckung(azubiId);
const gruppen = nachGruppen(positionen); const gruppen = nachGruppen(positionen);
const gesamt = bilanz(positionen); const gesamt = bilanz(positionen);
// Die Regel steht in lib/auswertung/abdeckung – der Text nennt nur, was an ist.
const belastbar = [
eingeschaltet.aufgaben ? 'eine abgenommene Praxisaufgabe' : null,
eingeschaltet.checklisten ? 'einen abgeschlossenen Checklisten-Durchlauf' : null,
].filter((teil) => teil !== null);
return ( return (
<section className="stapel" aria-labelledby="rahmenplan"> <section className="stapel" aria-labelledby="rahmenplan">
@@ -192,8 +203,9 @@ function Rahmenplan({ azubiId }: { azubiId: string }) {
</h2> </h2>
<p className="leise" style={{ margin: 0 }}> <p className="leise" style={{ margin: 0 }}>
Die Ansicht, die im Zweifel gegenüber der IHK zählt. Als <strong>vermittelt</strong> gilt eine 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 Position, wenn es dazu{' '}
Checklisten-Durchlauf gibt – oder mindestens zwei gelesene Handouts. {belastbar.length > 0 ? `${belastbar.join(' oder ')} gibt – oder ` : ''}mindestens zwei gelesene
Handouts{belastbar.length > 0 ? '' : ' gibt'}.
</p> </p>
</div> </div>
@@ -339,6 +351,7 @@ function Lernfelder({ azubiId }: { azubiId: string }) {
function Kontrollverlauf({ azubiId }: { azubiId: string }) { function Kontrollverlauf({ azubiId }: { azubiId: string }) {
const ausbildung = ausbildungNachAzubi(azubiId); const ausbildung = ausbildungNachAzubi(azubiId);
const stand = eigenerStand(azubiId, ausbildung?.beginn ?? null); const stand = eigenerStand(azubiId, ausbildung?.beginn ?? null);
if (!stand.module.kontrollen) return null;
return <ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} />; return <ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} />;
} }
@@ -17,6 +17,9 @@ import { Formulierungshilfe } from '../Formulierungshilfe';
import { BerichtsMarke } from '../BerichtsMarke'; import { BerichtsMarke } from '../BerichtsMarke';
import { Abzeichnen } from './Abzeichnen'; import { Abzeichnen } from './Abzeichnen';
import { blattZurueckziehen, blattZumLesen } from '../aktionen'; import { blattZurueckziehen, blattZumLesen } from '../aktionen';
import { erfordereModul } from '@/lib/module/pruefen';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { modulStand } from '@/lib/repos/module';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -27,6 +30,8 @@ export async function generateMetadata({ params }: { params: Promise<{ berichtId
} }
export default async function BlattSeite({ params }: { params: Promise<{ berichtId: string }> }) { export default async function BlattSeite({ params }: { params: Promise<{ berichtId: string }> }) {
await erfordereBenutzer();
erfordereModul('berichtsheft');
const { berichtId } = await params; const { berichtId } = await params;
// Rechteprüfung steckt hier drin: kein Zugriff bedeutet null bedeutet 404. // Rechteprüfung steckt hier drin: kein Zugriff bedeutet null bedeutet 404.
@@ -238,6 +243,8 @@ function AnsichtFuerAusbilder({
/** Stellt zusammen, was die Anwendung über diese Woche weiß. */ /** Stellt zusammen, was die Anwendung über diese Woche weiß. */
function sammleVorschlaege(azubiId: string, von: string, bis: string) { function sammleVorschlaege(azubiId: string, von: string, bis: string) {
const alle = inhalte(); const alle = inhalte();
// Aus abgeschalteten Modulen kommen keine Vorschläge.
const eingeschaltet = modulStand();
const notizen = notizenIm(azubiId, von, bis).map((n) => ({ const notizen = notizenIm(azubiId, von, bis).map((n) => ({
datum: n.datum, datum: n.datum,
@@ -247,7 +254,7 @@ function sammleVorschlaege(azubiId: string, von: string, bis: string) {
// Praxisaufgaben, die in dieser Woche abgegeben oder abgenommen wurden. // Praxisaufgaben, die in dieser Woche abgegeben oder abgenommen wurden.
const staende = aufgabenStaende(azubiId); const staende = aufgabenStaende(azubiId);
const aufgaben = [...staende.values()] const aufgaben = [...(eingeschaltet.aufgaben ? staende.values() : [])]
.filter((s) => { .filter((s) => {
const zeitpunkt = (s.abgenommenAm ?? s.abgegebenAm ?? '').slice(0, 10); const zeitpunkt = (s.abgenommenAm ?? s.abgegebenAm ?? '').slice(0, 10);
return zeitpunkt >= von && zeitpunkt <= bis; return zeitpunkt >= von && zeitpunkt <= bis;
@@ -262,7 +269,7 @@ function sammleVorschlaege(azubiId: string, von: string, bis: string) {
}; };
}); });
const checklisten = laeufeVon(azubiId) const checklisten = (eingeschaltet.checklisten ? laeufeVon(azubiId) : [])
.filter((l) => l.abgeschlossenAm !== null && l.datum >= von && l.datum <= bis) .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 })); .map((l) => ({ id: l.id, titel: l.titel, ticket: l.ticket, kunde: l.kunde }));
@@ -5,11 +5,15 @@ import { berichtsheftPdf } from '@/lib/pdf/berichtsheft';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit'; import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { ausbildungsjahr } from '@/lib/berichtsheft/jahr'; import { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
import { blattZumLesen } from '../../aktionen'; import { blattZumLesen } from '../../aktionen';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { modulAn, modulGesperrt } from '@/lib/module/pruefen';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
/** Ein einzelnes Wochenblatt als PDF. Rechteprüfung über `blattZumLesen`. */ /** Ein einzelnes Wochenblatt als PDF. Rechteprüfung über `blattZumLesen`. */
export async function GET(_anfrage: Request, kontext: { params: Promise<{ berichtId: string }> }) { export async function GET(_anfrage: Request, kontext: { params: Promise<{ berichtId: string }> }) {
await erfordereBenutzer();
if (!modulAn('berichtsheft')) return modulGesperrt();
const { berichtId } = await kontext.params; const { berichtId } = await kontext.params;
const gelesen = await blattZumLesen(berichtId); const gelesen = await blattZumLesen(berichtId);
@@ -14,6 +14,7 @@ import {
zurueckgeben, zurueckgeben,
} from '@/lib/repos/berichtsheft'; } from '@/lib/repos/berichtsheft';
import type { Wochenblatt } from '@/lib/berichtsheft/pruefung'; import type { Wochenblatt } from '@/lib/berichtsheft/pruefung';
import { pruefeModul } from '@/lib/module/pruefen';
export type Rueckmeldung = { fehler?: string; erfolg?: string }; export type Rueckmeldung = { fehler?: string; erfolg?: string };
@@ -31,6 +32,7 @@ async function blattZumBearbeiten(berichtId: string) {
/** Zum Lesen: das eigene Blatt oder das eines betreuten Azubis. */ /** Zum Lesen: das eigene Blatt oder das eines betreuten Azubis. */
export async function blattZumLesen(berichtId: string) { export async function blattZumLesen(berichtId: string) {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('berichtsheft');
const bericht = berichtNachId(berichtId); const bericht = berichtNachId(berichtId);
if (!bericht) return null; if (!bericht) return null;
@@ -50,6 +52,7 @@ function aktualisiere(berichtId?: string) {
export async function tagesnotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function tagesnotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('berichtsheft');
if (benutzer.rolle !== 'azubi') return { fehler: 'Tagesnotizen schreibt der Azubi.' }; if (benutzer.rolle !== 'azubi') return { fehler: 'Tagesnotizen schreibt der Azubi.' };
const datum = String(formular.get('datum') ?? '').trim(); const datum = String(formular.get('datum') ?? '').trim();
@@ -70,6 +73,7 @@ export async function tagesnotizSpeichern(_vorher: Rueckmeldung, formular: FormD
export async function blattAnlegen(formular: FormData): Promise<void> { export async function blattAnlegen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('berichtsheft');
if (benutzer.rolle !== 'azubi') return; if (benutzer.rolle !== 'azubi') return;
const datum = String(formular.get('datum') ?? '').trim(); const datum = String(formular.get('datum') ?? '').trim();
@@ -104,6 +108,7 @@ function leseBlatt(formular: FormData): Wochenblatt {
} }
export async function blattSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function blattSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
pruefeModul('berichtsheft');
const berichtId = String(formular.get('berichtId') ?? ''); const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId); const { bericht } = await blattZumBearbeiten(berichtId);
@@ -128,6 +133,7 @@ export async function blattSpeichern(_vorher: Rueckmeldung, formular: FormData):
* auf der Seite gesehen; ob er sie beherzigt, entscheidet er. * auf der Seite gesehen; ob er sie beherzigt, entscheidet er.
*/ */
export async function blattEinreichen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function blattEinreichen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
pruefeModul('berichtsheft');
const berichtId = String(formular.get('berichtId') ?? ''); const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId); const { bericht } = await blattZumBearbeiten(berichtId);
@@ -143,6 +149,7 @@ export async function blattEinreichen(_vorher: Rueckmeldung, formular: FormData)
} }
export async function blattZurueckziehen(formular: FormData): Promise<void> { export async function blattZurueckziehen(formular: FormData): Promise<void> {
pruefeModul('berichtsheft');
const berichtId = String(formular.get('berichtId') ?? ''); const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId); const { bericht } = await blattZumBearbeiten(berichtId);
if (bericht.status !== 'eingereicht') return; if (bericht.status !== 'eingereicht') return;
@@ -155,6 +162,7 @@ export async function blattZurueckziehen(formular: FormData): Promise<void> {
export async function blattAbzeichnen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function blattAbzeichnen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer(); const ausbilder = await erfordereBenutzer();
pruefeModul('berichtsheft');
const berichtId = String(formular.get('berichtId') ?? ''); const berichtId = String(formular.get('berichtId') ?? '');
const bericht = berichtNachId(berichtId); const bericht = berichtNachId(berichtId);
@@ -173,6 +181,7 @@ export async function blattAbzeichnen(_vorher: Rueckmeldung, formular: FormData)
export async function blattZurueckgeben(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function blattZurueckgeben(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer(); const ausbilder = await erfordereBenutzer();
pruefeModul('berichtsheft');
const berichtId = String(formular.get('berichtId') ?? ''); const berichtId = String(formular.get('berichtId') ?? '');
const bericht = berichtNachId(berichtId); const bericht = berichtNachId(berichtId);
@@ -9,12 +9,14 @@ import { Tagesnotizen, type Tagesfeld } from './Tagesnotizen';
import { Formulierungshilfe } from './Formulierungshilfe'; import { Formulierungshilfe } from './Formulierungshilfe';
import { blattAnlegen } from './aktionen'; import { blattAnlegen } from './aktionen';
import { BerichtsMarke } from './BerichtsMarke'; import { BerichtsMarke } from './BerichtsMarke';
import { erfordereModul } from '@/lib/module/pruefen';
export const metadata: Metadata = { title: 'Berichtsheft' }; export const metadata: Metadata = { title: 'Berichtsheft' };
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export default async function BerichtsheftSeite() { export default async function BerichtsheftSeite() {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
erfordereModul('berichtsheft');
return benutzer.rolle === 'ausbilder' ? ( return benutzer.rolle === 'ausbilder' ? (
<AusbilderSicht ausbilderId={benutzer.id} /> <AusbilderSicht ausbilderId={benutzer.id} />
@@ -5,6 +5,7 @@ import { alsWochenblatt, berichteVon } from '@/lib/repos/berichtsheft';
import { berichtsheftPdf, type Blattangaben } from '@/lib/pdf/berichtsheft'; import { berichtsheftPdf, type Blattangaben } from '@/lib/pdf/berichtsheft';
import { ausbildungsjahr } from '@/lib/berichtsheft/jahr'; import { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit'; import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { modulAn, modulGesperrt } from '@/lib/module/pruefen';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -20,6 +21,7 @@ const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
*/ */
export async function GET(anfrage: Request) { export async function GET(anfrage: Request) {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
if (!modulAn('berichtsheft')) return modulGesperrt();
const adresse = new URL(anfrage.url); const adresse = new URL(anfrage.url);
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (adresse.searchParams.get('azubi') ?? ''); const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (adresse.searchParams.get('azubi') ?? '');
@@ -19,6 +19,7 @@ import {
standVon, standVon,
} from '@/lib/repos/checklisten'; } from '@/lib/repos/checklisten';
import { darfAbschliessen } from '@/lib/checklisten/fortschritt'; import { darfAbschliessen } from '@/lib/checklisten/fortschritt';
import { pruefeModul } from '@/lib/module/pruefen';
export type Rueckmeldung = { fehler?: string; erfolg?: string }; export type Rueckmeldung = { fehler?: string; erfolg?: string };
@@ -42,6 +43,7 @@ async function laufZumBearbeiten(laufId: string) {
/** Zum Lesen: der eigene Durchlauf oder der eines betreuten Azubis. */ /** Zum Lesen: der eigene Durchlauf oder der eines betreuten Azubis. */
export async function laufZumLesen(laufId: string) { export async function laufZumLesen(laufId: string) {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('checklisten');
const lauf = laufNachId(laufId); const lauf = laufNachId(laufId);
if (!lauf) return null; if (!lauf) return null;
@@ -59,6 +61,7 @@ function aktualisiere(laufId: string) {
export async function durchlaufStarten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function durchlaufStarten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('checklisten');
if (benutzer.rolle !== 'azubi') { if (benutzer.rolle !== 'azubi') {
return { fehler: 'Durchläufe führt der Azubi – der Ausbilder liest mit.' }; return { fehler: 'Durchläufe führt der Azubi – der Ausbilder liest mit.' };
} }
@@ -82,6 +85,7 @@ export async function durchlaufStarten(_vorher: Rueckmeldung, formular: FormData
} }
export async function schrittUmschalten(formular: FormData): Promise<void> { export async function schrittUmschalten(formular: FormData): Promise<void> {
pruefeModul('checklisten');
const laufId = String(formular.get('laufId') ?? ''); const laufId = String(formular.get('laufId') ?? '');
const schrittId = String(formular.get('schrittId') ?? ''); const schrittId = String(formular.get('schrittId') ?? '');
const erledigt = String(formular.get('erledigt') ?? '') === '1'; const erledigt = String(formular.get('erledigt') ?? '') === '1';
@@ -94,6 +98,7 @@ export async function schrittUmschalten(formular: FormData): Promise<void> {
} }
export async function schrittBemerkung(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function schrittBemerkung(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
pruefeModul('checklisten');
const laufId = String(formular.get('laufId') ?? ''); const laufId = String(formular.get('laufId') ?? '');
const schrittId = String(formular.get('schrittId') ?? ''); const schrittId = String(formular.get('schrittId') ?? '');
const bemerkung = String(formular.get('bemerkung') ?? ''); const bemerkung = String(formular.get('bemerkung') ?? '');
@@ -108,6 +113,7 @@ export async function schrittBemerkung(_vorher: Rueckmeldung, formular: FormData
} }
export async function kopfdatenSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function kopfdatenSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
pruefeModul('checklisten');
const laufId = String(formular.get('laufId') ?? ''); const laufId = String(formular.get('laufId') ?? '');
const { lauf } = await laufZumBearbeiten(laufId); const { lauf } = await laufZumBearbeiten(laufId);
if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist abgeschlossen.' }; if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist abgeschlossen.' };
@@ -130,6 +136,7 @@ export async function kopfdatenSpeichern(_vorher: Rueckmeldung, formular: FormDa
} }
export async function durchlaufAbschliessen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function durchlaufAbschliessen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
pruefeModul('checklisten');
const laufId = String(formular.get('laufId') ?? ''); const laufId = String(formular.get('laufId') ?? '');
const { lauf } = await laufZumBearbeiten(laufId); const { lauf } = await laufZumBearbeiten(laufId);
if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist schon abgeschlossen.' }; if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist schon abgeschlossen.' };
@@ -148,6 +155,7 @@ export async function durchlaufAbschliessen(_vorher: Rueckmeldung, formular: For
} }
export async function durchlaufWiederOeffnen(formular: FormData): Promise<void> { export async function durchlaufWiederOeffnen(formular: FormData): Promise<void> {
pruefeModul('checklisten');
const laufId = String(formular.get('laufId') ?? ''); const laufId = String(formular.get('laufId') ?? '');
await laufZumBearbeiten(laufId); await laufZumBearbeiten(laufId);
laufWiederOeffnen(laufId); laufWiederOeffnen(laufId);
@@ -155,6 +163,7 @@ export async function durchlaufWiederOeffnen(formular: FormData): Promise<void>
} }
export async function durchlaufVerwerfen(formular: FormData): Promise<void> { export async function durchlaufVerwerfen(formular: FormData): Promise<void> {
pruefeModul('checklisten');
const laufId = String(formular.get('laufId') ?? ''); const laufId = String(formular.get('laufId') ?? '');
const { lauf } = await laufZumBearbeiten(laufId); const { lauf } = await laufZumBearbeiten(laufId);
// Ein abgeschlossener Durchlauf ist ein Nachweis und wird nicht gelöscht. // Ein abgeschlossener Durchlauf ist ein Nachweis und wird nicht gelöscht.
@@ -7,6 +7,8 @@ import { standVon } from '@/lib/repos/checklisten';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit'; import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { AbschlussFormular, BemerkungFormular, KopfdatenFormular } from '../../Formulare'; import { AbschlussFormular, BemerkungFormular, KopfdatenFormular } from '../../Formulare';
import { durchlaufVerwerfen, durchlaufWiederOeffnen, laufZumLesen, schrittUmschalten } from '../../aktionen'; import { durchlaufVerwerfen, durchlaufWiederOeffnen, laufZumLesen, schrittUmschalten } from '../../aktionen';
import { erfordereModul } from '@/lib/module/pruefen';
import { erfordereBenutzer } from '@/lib/auth/rechte';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -17,6 +19,8 @@ export async function generateMetadata({ params }: { params: Promise<{ laufId: s
} }
export default async function LaufSeite({ params }: { params: Promise<{ laufId: string }> }) { export default async function LaufSeite({ params }: { params: Promise<{ laufId: string }> }) {
await erfordereBenutzer();
erfordereModul('checklisten');
const { laufId } = await params; const { laufId } = await params;
// Enthält die Rechteprüfung: Wer nicht darf, bekommt null – und damit 404. // Enthält die Rechteprüfung: Wer nicht darf, bekommt null – und damit 404.
@@ -4,6 +4,8 @@ import { standVon } from '@/lib/repos/checklisten';
import { checklistenPdf } from '@/lib/pdf/checkliste'; import { checklistenPdf } from '@/lib/pdf/checkliste';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit'; import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { laufZumLesen } from '../../../aktionen'; import { laufZumLesen } from '../../../aktionen';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { modulAn, modulGesperrt } from '@/lib/module/pruefen';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -15,6 +17,8 @@ export const dynamic = 'force-dynamic';
* gibt, geht einen Unbefugten nichts an. * gibt, geht einen Unbefugten nichts an.
*/ */
export async function GET(_anfrage: Request, kontext: { params: Promise<{ laufId: string }> }) { export async function GET(_anfrage: Request, kontext: { params: Promise<{ laufId: string }> }) {
await erfordereBenutzer();
if (!modulAn('checklisten')) return modulGesperrt();
const { laufId } = await kontext.params; const { laufId } = await kontext.params;
const gelesen = await laufZumLesen(laufId); const gelesen = await laufZumLesen(laufId);
@@ -6,12 +6,14 @@ import { inhalte } from '@/lib/inhalte';
import { laeufeVon, standVon } from '@/lib/repos/checklisten'; import { laeufeVon, standVon } from '@/lib/repos/checklisten';
import { alsDatumText, alsZeitpunktText, heute } from '@/lib/zeit'; import { alsDatumText, alsZeitpunktText, heute } from '@/lib/zeit';
import { StartFormular } from './Formulare'; import { StartFormular } from './Formulare';
import { erfordereModul } from '@/lib/module/pruefen';
export const metadata: Metadata = { title: 'Checklisten' }; export const metadata: Metadata = { title: 'Checklisten' };
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export default async function ChecklistenSeite() { export default async function ChecklistenSeite() {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
erfordereModul('checklisten');
const alle = inhalte(); const alle = inhalte();
const istAzubi = benutzer.rolle === 'azubi'; const istAzubi = benutzer.rolle === 'azubi';
@@ -8,6 +8,8 @@ import { istAbgeschlossen, zusammenfassung } from '@/lib/feedback/inhalt';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit'; import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { Gespraechsformular } from '../Gespraechsformular'; import { Gespraechsformular } from '../Gespraechsformular';
import { bestaetigungZurueck, gespraechVerwerfen, gespraechZumLesen } from '../aktionen'; import { bestaetigungZurueck, gespraechVerwerfen, gespraechZumLesen } from '../aktionen';
import { erfordereModul } from '@/lib/module/pruefen';
import { erfordereBenutzer } from '@/lib/auth/rechte';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -18,6 +20,8 @@ export async function generateMetadata({ params }: { params: Promise<{ gespraech
} }
export default async function GespraechsSeite({ params }: { params: Promise<{ gespraechId: string }> }) { export default async function GespraechsSeite({ params }: { params: Promise<{ gespraechId: string }> }) {
await erfordereBenutzer();
erfordereModul('feedback');
const { gespraechId } = await params; const { gespraechId } = await params;
const gelesen = await gespraechZumLesen(gespraechId); const gelesen = await gespraechZumLesen(gespraechId);
@@ -4,11 +4,15 @@ import { inhaltVon } from '@/lib/repos/feedback';
import { feedbackPdf } from '@/lib/pdf/feedback'; import { feedbackPdf } from '@/lib/pdf/feedback';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit'; import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { gespraechZumLesen } from '../../aktionen'; import { gespraechZumLesen } from '../../aktionen';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { modulAn, modulGesperrt } from '@/lib/module/pruefen';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
/** Das Feedbackgespräch als PDF. Rechteprüfung über `gespraechZumLesen`. */ /** Das Feedbackgespräch als PDF. Rechteprüfung über `gespraechZumLesen`. */
export async function GET(_anfrage: Request, kontext: { params: Promise<{ gespraechId: string }> }) { export async function GET(_anfrage: Request, kontext: { params: Promise<{ gespraechId: string }> }) {
await erfordereBenutzer();
if (!modulAn('feedback')) return modulGesperrt();
const { gespraechId } = await kontext.params; const { gespraechId } = await kontext.params;
const gelesen = await gespraechZumLesen(gespraechId); const gelesen = await gespraechZumLesen(gespraechId);
@@ -16,6 +16,7 @@ import {
loeschen, loeschen,
} from '@/lib/repos/feedback'; } from '@/lib/repos/feedback';
import { istAbgeschlossen, type Gespraechsinhalt } from '@/lib/feedback/inhalt'; import { istAbgeschlossen, type Gespraechsinhalt } from '@/lib/feedback/inhalt';
import { pruefeModul } from '@/lib/module/pruefen';
export type Rueckmeldung = { fehler?: string; erfolg?: string }; export type Rueckmeldung = { fehler?: string; erfolg?: string };
@@ -30,6 +31,7 @@ const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
*/ */
export async function gespraechZumLesen(id: string) { export async function gespraechZumLesen(id: string) {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('feedback');
const gespraech = gespraechNachId(id); const gespraech = gespraechNachId(id);
if (!gespraech) return null; if (!gespraech) return null;
@@ -48,6 +50,7 @@ function aktualisiere(id?: string) {
export async function gespraechAnlegen(formular: FormData): Promise<void> { export async function gespraechAnlegen(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer(); const ausbilder = await erfordereBenutzer();
pruefeModul('feedback');
const azubiId = String(formular.get('azubiId') ?? ''); const azubiId = String(formular.get('azubiId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId); erfordereBewertungsrecht(ausbilder, azubiId);
@@ -67,6 +70,7 @@ export async function gespraechAnlegen(formular: FormData): Promise<void> {
} }
export async function gespraechSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function gespraechSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
pruefeModul('feedback');
const id = String(formular.get('gespraechId') ?? ''); const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id); const gelesen = await gespraechZumLesen(id);
if (!gelesen) return { fehler: 'Dieses Gespräch gibt es nicht.' }; if (!gelesen) return { fehler: 'Dieses Gespräch gibt es nicht.' };
@@ -125,6 +129,7 @@ export async function gespraechSpeichern(_vorher: Rueckmeldung, formular: FormDa
} }
export async function gespraechBestaetigen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function gespraechBestaetigen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
pruefeModul('feedback');
const id = String(formular.get('gespraechId') ?? ''); const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id); const gelesen = await gespraechZumLesen(id);
if (!gelesen) return { fehler: 'Dieses Gespräch gibt es nicht.' }; if (!gelesen) return { fehler: 'Dieses Gespräch gibt es nicht.' };
@@ -148,6 +153,7 @@ export async function gespraechBestaetigen(_vorher: Rueckmeldung, formular: Form
} }
export async function bestaetigungZurueck(formular: FormData): Promise<void> { export async function bestaetigungZurueck(formular: FormData): Promise<void> {
pruefeModul('feedback');
const id = String(formular.get('gespraechId') ?? ''); const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id); const gelesen = await gespraechZumLesen(id);
if (!gelesen) return; if (!gelesen) return;
@@ -164,6 +170,7 @@ export async function bestaetigungZurueck(formular: FormData): Promise<void> {
} }
export async function gespraechVerwerfen(formular: FormData): Promise<void> { export async function gespraechVerwerfen(formular: FormData): Promise<void> {
pruefeModul('feedback');
const id = String(formular.get('gespraechId') ?? ''); const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id); const gelesen = await gespraechZumLesen(id);
if (!gelesen) return; if (!gelesen) return;
+2
View File
@@ -7,12 +7,14 @@ import { gespraecheVon, inhaltVon } from '@/lib/repos/feedback';
import { istAbgeschlossen, zusammenfassung } from '@/lib/feedback/inhalt'; import { istAbgeschlossen, zusammenfassung } from '@/lib/feedback/inhalt';
import { alsDatumText, heute, montagDerWoche } from '@/lib/zeit'; import { alsDatumText, heute, montagDerWoche } from '@/lib/zeit';
import { gespraechAnlegen } from './aktionen'; import { gespraechAnlegen } from './aktionen';
import { erfordereModul } from '@/lib/module/pruefen';
export const metadata: Metadata = { title: 'Feedbackgespräche' }; export const metadata: Metadata = { title: 'Feedbackgespräche' };
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export default async function FeedbackSeite() { export default async function FeedbackSeite() {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
erfordereModul('feedback');
const vorlage = inhalte().feedback; const vorlage = inhalte().feedback;
const beteiligte = const beteiligte =
+21 -18
View File
@@ -8,6 +8,7 @@ import { gelesenAm } from '@/lib/repos/fortschritt';
import { alsZeitpunktText } from '@/lib/zeit'; import { alsZeitpunktText } from '@/lib/zeit';
import { gelesenUmschalten } from './aktionen'; import { gelesenUmschalten } from './aktionen';
import { Druckknopf } from '@/komponenten/Druckknopf'; import { Druckknopf } from '@/komponenten/Druckknopf';
import { modulStand } from '@/lib/repos/module';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -131,24 +132,26 @@ export default async function HandoutSeite({ params }: { params: Promise<{ id: s
</details> </details>
) : null} ) : null}
<section aria-labelledby="karten" className="karte stapel-eng"> {modulStand().karten ? (
<h2 id="karten" style={{ marginBottom: '0.3rem' }}> <section aria-labelledby="karten" className="karte stapel-eng">
Merkkarten zu diesem Handout <h2 id="karten" style={{ marginBottom: '0.3rem' }}>
</h2> Merkkarten zu diesem Handout
<p className="leise" style={{ margin: 0 }}> </h2>
{handout.karten.length === 1 ? 'Eine Karte' : `${handout.karten.length} Karten`} – im Lernmodus tauchen sie <p className="leise" style={{ margin: 0 }}>
nach Fälligkeit auf. {handout.karten.length === 1 ? 'Eine Karte' : `${handout.karten.length} Karten`} – im Lernmodus tauchen sie
</p> nach Fälligkeit auf.
<ol className="stapel-eng" style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}> </p>
{handout.karten.map((karte, index) => ( <ol className="stapel-eng" style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
<li key={index}> {handout.karten.map((karte, index) => (
<strong>{karte.frage}</strong> <li key={index}>
<br /> <strong>{karte.frage}</strong>
<span className="leise">{karte.antwort}</span> <br />
</li> <span className="leise">{karte.antwort}</span>
))} </li>
</ol> ))}
</section> </ol>
</section>
) : null}
{istAzubi ? ( {istAzubi ? (
<form action={gelesenUmschalten} className="nicht-drucken"> <form action={gelesenUmschalten} className="nicht-drucken">
+2
View File
@@ -6,6 +6,7 @@ import { inhalte } from '@/lib/inhalte';
import { bewerte } from '@/lib/repos/karten'; import { bewerte } from '@/lib/repos/karten';
import { bewertungen, fachNamen, type Bewertung } from '@/lib/lernen/sm2'; import { bewertungen, fachNamen, type Bewertung } from '@/lib/lernen/sm2';
import { alsDatumText, heute } from '@/lib/zeit'; import { alsDatumText, heute } from '@/lib/zeit';
import { pruefeModul } from '@/lib/module/pruefen';
export type Bewertungsergebnis = { export type Bewertungsergebnis = {
fehler?: string; fehler?: string;
@@ -25,6 +26,7 @@ export type Bewertungsergebnis = {
*/ */
export async function karteBewerten(karteId: string, bewertung: string): Promise<Bewertungsergebnis> { export async function karteBewerten(karteId: string, bewertung: string): Promise<Bewertungsergebnis> {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('karten');
if (benutzer.rolle !== 'azubi') { if (benutzer.rolle !== 'azubi') {
return { fehler: 'Nur der Azubi führt seinen eigenen Lernstand.' }; return { fehler: 'Nur der Azubi führt seinen eigenen Lernstand.' };
} }
@@ -7,6 +7,7 @@ import { alsKartenstand, staendeVon } from '@/lib/repos/karten';
import { fach, fachNamen, heutigerStapel } from '@/lib/lernen/sm2'; import { fach, fachNamen, heutigerStapel } from '@/lib/lernen/sm2';
import { heute } from '@/lib/zeit'; import { heute } from '@/lib/zeit';
import { Lernmodus, type Lernkarte } from './Lernmodus'; import { Lernmodus, type Lernkarte } from './Lernmodus';
import { erfordereModul } from '@/lib/module/pruefen';
export const metadata: Metadata = { title: 'Lernmodus' }; export const metadata: Metadata = { title: 'Lernmodus' };
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -15,6 +16,7 @@ const STAPELGROESSE = 20;
export default async function LernSeite({ searchParams }: { searchParams: Promise<{ block?: string }> }) { export default async function LernSeite({ searchParams }: { searchParams: Promise<{ block?: string }> }) {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
erfordereModul('karten');
if (benutzer.rolle !== 'azubi') redirect('/karten'); if (benutzer.rolle !== 'azubi') redirect('/karten');
const { block } = await searchParams; const { block } = await searchParams;
+2
View File
@@ -5,6 +5,7 @@ import { inhalte } from '@/lib/inhalte';
import { alsKartenstand, staendeVon, uebersicht } from '@/lib/repos/karten'; import { alsKartenstand, staendeVon, uebersicht } from '@/lib/repos/karten';
import { fach, fachNamen, heutigerStapel } from '@/lib/lernen/sm2'; import { fach, fachNamen, heutigerStapel } from '@/lib/lernen/sm2';
import { alsDatumText, heute } from '@/lib/zeit'; import { alsDatumText, heute } from '@/lib/zeit';
import { erfordereModul } from '@/lib/module/pruefen';
export const metadata: Metadata = { title: 'Merkkarten' }; export const metadata: Metadata = { title: 'Merkkarten' };
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -13,6 +14,7 @@ const STAPELGROESSE = 20;
export default async function KartenSeite() { export default async function KartenSeite() {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
erfordereModul('karten');
const alle = inhalte(); const alle = inhalte();
const heuteDatum = heute(); const heuteDatum = heute();
@@ -10,6 +10,7 @@ import { alsZeitpunktText } from '@/lib/zeit';
import { Fragebogen } from '../Fragebogen'; import { Fragebogen } from '../Fragebogen';
import { Korrektur, type Korrekturfrage } from '../Korrektur'; import { Korrektur, type Korrekturfrage } from '../Korrektur';
import { standZumLesen } from '../aktionen'; import { standZumLesen } from '../aktionen';
import { erfordereModul } from '@/lib/module/pruefen';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -27,6 +28,7 @@ export default async function KontrollSeite({
searchParams: Promise<{ azubi?: string }>; searchParams: Promise<{ azubi?: string }>;
}) { }) {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
erfordereModul('kontrollen');
const { kontrolleId } = await params; const { kontrolleId } = await params;
const { azubi: azubiParameter } = await searchParams; const { azubi: azubiParameter } = await searchParams;
@@ -14,6 +14,7 @@ import {
standVon, standVon,
} from '@/lib/repos/kontrollen'; } from '@/lib/repos/kontrollen';
import { darfBearbeiten } from '@/lib/kontrollen/sicht'; import { darfBearbeiten } from '@/lib/kontrollen/sicht';
import { pruefeModul } from '@/lib/module/pruefen';
export type Rueckmeldung = { fehler?: string; erfolg?: string }; export type Rueckmeldung = { fehler?: string; erfolg?: string };
@@ -36,6 +37,7 @@ export async function antwortZwischenspeichern(
antwort: string, antwort: string,
): Promise<{ gespeichertUm: string } | { fehler: string }> { ): Promise<{ gespeichertUm: string } | { fehler: string }> {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('kontrollen');
const inhalt = inhalte().kontrolleNach.get(kontrolleId); const inhalt = inhalte().kontrolleNach.get(kontrolleId);
if (!inhalt) return { fehler: 'Diese Lernzielkontrolle gibt es nicht.' }; if (!inhalt) return { fehler: 'Diese Lernzielkontrolle gibt es nicht.' };
@@ -57,6 +59,7 @@ export async function antwortZwischenspeichern(
export async function kontrolleAbgeben(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function kontrolleAbgeben(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('kontrollen');
const kontrolleId = String(formular.get('kontrolleId') ?? ''); const kontrolleId = String(formular.get('kontrolleId') ?? '');
const inhalt = inhalte().kontrolleNach.get(kontrolleId); const inhalt = inhalte().kontrolleNach.get(kontrolleId);
@@ -84,6 +87,7 @@ export async function kontrolleAbgeben(_vorher: Rueckmeldung, formular: FormData
export async function kontrolleFreigeben(formular: FormData): Promise<void> { export async function kontrolleFreigeben(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer(); const ausbilder = await erfordereBenutzer();
pruefeModul('kontrollen');
const azubiId = String(formular.get('azubiId') ?? ''); const azubiId = String(formular.get('azubiId') ?? '');
const kontrolleId = String(formular.get('kontrolleId') ?? ''); const kontrolleId = String(formular.get('kontrolleId') ?? '');
@@ -96,6 +100,7 @@ export async function kontrolleFreigeben(formular: FormData): Promise<void> {
export async function freigabeZurueck(formular: FormData): Promise<void> { export async function freigabeZurueck(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer(); const ausbilder = await erfordereBenutzer();
pruefeModul('kontrollen');
const azubiId = String(formular.get('azubiId') ?? ''); const azubiId = String(formular.get('azubiId') ?? '');
const kontrolleId = String(formular.get('kontrolleId') ?? ''); const kontrolleId = String(formular.get('kontrolleId') ?? '');
@@ -111,6 +116,7 @@ export async function freigabeZurueck(formular: FormData): Promise<void> {
export async function kontrolleKorrigieren(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function kontrolleKorrigieren(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer(); const ausbilder = await erfordereBenutzer();
pruefeModul('kontrollen');
const azubiId = String(formular.get('azubiId') ?? ''); const azubiId = String(formular.get('azubiId') ?? '');
const kontrolleId = String(formular.get('kontrolleId') ?? ''); const kontrolleId = String(formular.get('kontrolleId') ?? '');
@@ -153,6 +159,7 @@ export async function kontrolleKorrigieren(_vorher: Rueckmeldung, formular: Form
/** Prüft den Lesezugriff und liefert den Stand. Wird von Seiten benutzt. */ /** Prüft den Lesezugriff und liefert den Stand. Wird von Seiten benutzt. */
export async function standZumLesen(azubiId: string, kontrolleId: string) { export async function standZumLesen(azubiId: string, kontrolleId: string) {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
pruefeModul('kontrollen');
const bezug = ausbildungVonAzubi(azubiId); const bezug = ausbildungVonAzubi(azubiId);
if (!darfAzubiDatenSehen(benutzer, azubiId, bezug)) return null; if (!darfAzubiDatenSehen(benutzer, azubiId, bezug)) return null;
+2
View File
@@ -8,6 +8,7 @@ import { gesamtpunkte, zustandVon, type Zustand } from '@/lib/kontrollen/sicht';
import { verlauf } from '@/lib/kontrollen/bewertung'; import { verlauf } from '@/lib/kontrollen/bewertung';
import { alsZeitpunktText } from '@/lib/zeit'; import { alsZeitpunktText } from '@/lib/zeit';
import { kontrolleFreigeben, freigabeZurueck } from './aktionen'; import { kontrolleFreigeben, freigabeZurueck } from './aktionen';
import { erfordereModul } from '@/lib/module/pruefen';
export const metadata: Metadata = { title: 'Lernzielkontrollen' }; export const metadata: Metadata = { title: 'Lernzielkontrollen' };
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -21,6 +22,7 @@ const zustandsmarke: Record<Zustand, { text: string; ton: string; zeichen: strin
export default async function KontrollenSeite() { export default async function KontrollenSeite() {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
erfordereModul('kontrollen');
const alle = inhalte(); const alle = inhalte();
if (benutzer.rolle === 'azubi') { if (benutzer.rolle === 'azubi') {
+4 -1
View File
@@ -2,6 +2,8 @@ import { erfordereBenutzer } from '@/lib/auth/rechte';
import { Kopfzeile, type Navigationseintrag } from '@/komponenten/Kopfzeile'; import { Kopfzeile, type Navigationseintrag } from '@/komponenten/Kopfzeile';
import { Netzzustand } from '@/komponenten/Netzzustand'; import { Netzzustand } from '@/komponenten/Netzzustand';
import { abmelden } from '../anmelden/aktionen'; import { abmelden } from '../anmelden/aktionen';
import { modulStand } from '@/lib/repos/module';
import { nurOffene } from '@/lib/module/module';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -37,7 +39,8 @@ const fuerAusbilder: Navigationseintrag[] = [
export default async function AngemeldetLayout({ children }: { children: React.ReactNode }) { export default async function AngemeldetLayout({ children }: { children: React.ReactNode }) {
const benutzer = await erfordereBenutzer(); const benutzer = await erfordereBenutzer();
const eintraege = benutzer.rolle === 'ausbilder' ? fuerAusbilder : fuerAzubi; // Abgeschaltete Module verschwinden aus der Navigation (lib/module).
const eintraege = nurOffene(benutzer.rolle === 'ausbilder' ? fuerAusbilder : fuerAzubi, modulStand());
return ( return (
<> <>
+13 -4
View File
@@ -10,6 +10,7 @@ import { wartetAufAbzeichnung } from '@/lib/repos/berichtsheft';
import { wartetAufKorrektur } from '@/lib/repos/kontrollen'; import { wartetAufKorrektur } from '@/lib/repos/kontrollen';
import { benutzerNachId } from '@/lib/repos/benutzer'; import { benutzerNachId } from '@/lib/repos/benutzer';
import { WochenAnsicht } from './WochenAnsicht'; import { WochenAnsicht } from './WochenAnsicht';
import { modulStand } from '@/lib/repos/module';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -151,10 +152,18 @@ function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
const meine = ausbildungenVonAusbilder(ausbilderId); const meine = ausbildungenVonAusbilder(ausbilderId);
const alle = inhalte(); const alle = inhalte();
const azubiIds = meine.map(({ azubi }) => azubi.id); const azubiIds = meine.map(({ azubi }) => azubi.id);
const abnahmen = wartetAufAbnahme(azubiIds); // Nur was zu eingeschalteten Modulen gehört – eine Abnahme, die man nicht
const berichte = wartetAufAbzeichnung(azubiIds); // öffnen kann, wäre ein toter Verweis.
const kontrollen = wartetAufKorrektur(azubiIds); const eingeschaltet = modulStand();
const abnahmen = eingeschaltet.aufgaben ? wartetAufAbnahme(azubiIds) : [];
const berichte = eingeschaltet.berichtsheft ? wartetAufAbzeichnung(azubiIds) : [];
const kontrollen = eingeschaltet.kontrollen ? wartetAufKorrektur(azubiIds) : [];
const gesamt = abnahmen.length + berichte.length + kontrollen.length; const gesamt = abnahmen.length + berichte.length + kontrollen.length;
const art = [
eingeschaltet.aufgaben ? 'keine Abnahme' : null,
eingeschaltet.kontrollen ? 'keine Korrektur' : null,
eingeschaltet.berichtsheft ? 'kein Berichtsheft' : null,
].filter((teil) => teil !== null);
return ( return (
<section className="karte stapel-eng" aria-labelledby="wartet"> <section className="karte stapel-eng" aria-labelledby="wartet">
@@ -164,7 +173,7 @@ function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
{gesamt === 0 ? ( {gesamt === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}> <p className="leise" style={{ marginBottom: 0 }}>
Nichts offen – keine Abnahme, keine Korrektur, kein Berichtsheft. {art.length > 0 ? `Nichts offen – ${art.join(', ')}.` : 'Nichts offen.'}
</p> </p>
) : ( ) : (
<ul className="offen-liste"> <ul className="offen-liste">
@@ -0,0 +1,23 @@
import Link from 'next/link';
/** Die beiden Bereiche der Verwaltung. */
export function Unternavigation({ aktiv }: { aktiv: 'konten' | 'module' }) {
return (
<nav className="reihe nicht-drucken" aria-label="Bereiche der Verwaltung">
<Link
href="/verwaltung"
className={`knopf ${aktiv === 'konten' ? '' : 'knopf-zweit'}`}
aria-current={aktiv === 'konten' ? 'page' : undefined}
>
Konten
</Link>
<Link
href="/verwaltung/module"
className={`knopf ${aktiv === 'module' ? '' : 'knopf-zweit'}`}
aria-current={aktiv === 'module' ? 'page' : undefined}
>
Module
</Link>
</nav>
);
}
@@ -0,0 +1,19 @@
'use server';
import { revalidatePath } from 'next/cache';
import { erfordereAusbilder } from '@/lib/auth/rechte';
import { istModulId } from '@/lib/module/module';
import { modulSetzen } from '@/lib/repos/module';
/** Schaltet ein Modul für die ganze Lern-Bar ein oder aus. Nur Ausbilder. */
export async function modulUmschalten(formular: FormData): Promise<void> {
const ausbilder = await erfordereAusbilder();
const modul = String(formular.get('modul') ?? '');
const aktiv = String(formular.get('aktiv') ?? '') === '1';
if (!istModulId(modul)) return;
modulSetzen(modul, aktiv, ausbilder.id);
// Navigation, Startseite, Auswertung – alles hängt am Modulstand.
revalidatePath('/', 'layout');
}
@@ -0,0 +1,81 @@
import type { Metadata } from 'next';
import { erfordereAusbilder } from '@/lib/auth/rechte';
import { modulIds, moduleintraege } from '@/lib/module/module';
import { modulAenderungen, modulStand } from '@/lib/repos/module';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { alsZeitpunktText } from '@/lib/zeit';
import { Unternavigation } from '../Unternavigation';
import { modulUmschalten } from './aktionen';
export const metadata: Metadata = { title: 'Module' };
export const dynamic = 'force-dynamic';
export default async function ModulSeite() {
await erfordereAusbilder();
const stand = modulStand();
const aenderungen = new Map(modulAenderungen().map((zeile) => [zeile.modul, zeile]));
return (
<div className="stapel">
<div>
<h1>Verwaltung</h1>
<p className="leise">
Module, die ihr nicht braucht, lassen sich abschalten – für die ganze Lern-Bar, für alle Azubis und
Ausbilder zugleich.
</p>
</div>
<Unternavigation aktiv="module" />
<p className="hinweis">
<strong>Abschalten löscht nichts.</strong> Ein abgeschaltetes Modul verschwindet aus Navigation, Wochenplan
und Auswertung, und seine Seiten sind gesperrt. Was darin schon geschrieben wurde, bleibt gespeichert und ist
nach dem Einschalten unverändert wieder da. Wochenplan, Handouts, Glossar, Auswertung und Verwaltung sind
immer an.
</p>
<div className="stapel">
{modulIds.map((id) => {
const eintrag = moduleintraege[id];
const an = stand[id];
const aenderung = aenderungen.get(id);
const wer = aenderung?.geaendertVon ? benutzerNachId(aenderung.geaendertVon)?.name : null;
return (
<section key={id} className="karte stapel-eng" aria-labelledby={`modul-${id}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<div className="reihe" style={{ gap: '0.6rem' }}>
<h2 id={`modul-${id}`} style={{ margin: 0, fontSize: '1.05rem' }}>
{eintrag.titel}
</h2>
<span className="marke-status" data-ton={an ? 'gut' : 'warnung'}>
{an ? '● an' : '○ aus'}
</span>
</div>
<form action={modulUmschalten}>
<input type="hidden" name="modul" value={id} />
<input type="hidden" name="aktiv" value={an ? '0' : '1'} />
<button type="submit" className={`knopf ${an ? 'knopf-zweit' : ''}`}>
{an ? 'Abschalten' : 'Einschalten'}
</button>
</form>
</div>
<p style={{ margin: 0 }}>{eintrag.beschreibung}</p>
<p className="leise" style={{ margin: 0 }}>
<strong>Wenn aus:</strong> {eintrag.folgen}
</p>
{aenderung ? (
<p className="leise" style={{ margin: 0 }}>
Zuletzt {aenderung.aktiv === 1 ? 'eingeschaltet' : 'abgeschaltet'} am{' '}
{alsZeitpunktText(aenderung.geaendertAm)}
{wer ? ` von ${wer}` : ''}.
</p>
) : null}
</section>
);
})}
</div>
</div>
);
}
+5 -1
View File
@@ -4,6 +4,7 @@ import { ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { alsDatumText, alsZeitpunktText, heute } from '@/lib/zeit'; import { alsDatumText, alsZeitpunktText, heute } from '@/lib/zeit';
import { AusbildungFormular, AzubiAnlegenFormular, PasswortFormular } from './Formulare'; import { AusbildungFormular, AzubiAnlegenFormular, PasswortFormular } from './Formulare';
import { kontoAktivSetzen } from './aktionen'; import { kontoAktivSetzen } from './aktionen';
import { Unternavigation } from './Unternavigation';
export const metadata: Metadata = { title: 'Verwaltung' }; export const metadata: Metadata = { title: 'Verwaltung' };
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -17,10 +18,13 @@ export default async function VerwaltungsSeite() {
<div> <div>
<h1>Verwaltung</h1> <h1>Verwaltung</h1>
<p className="leise"> <p className="leise">
Hier legst du Azubikonten an und pflegst die Eckdaten der Ausbildung. Mehr Verwaltung gibt es bewusst nicht. Hier legst du Azubikonten an und pflegst die Eckdaten der Ausbildung. Unter &bdquo;Module&ldquo; schaltest du ab, was
ihr nicht braucht.
</p> </p>
</div> </div>
<Unternavigation aktiv="konten" />
<section className="karte stapel" aria-labelledby="ueberschrift-azubis"> <section className="karte stapel" aria-labelledby="ueberschrift-azubis">
<h2 id="ueberschrift-azubis" style={{ marginBottom: 0 }}> <h2 id="ueberschrift-azubis" style={{ marginBottom: 0 }}>
Deine Auszubildenden Deine Auszubildenden
+2
View File
@@ -3,6 +3,7 @@ import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { ausbildungVonAzubi } from '@/lib/auth/rechte'; import { ausbildungVonAzubi } from '@/lib/auth/rechte';
import { anhangLesen, anhangNachId } from '@/lib/repos/anhaenge'; import { anhangLesen, anhangNachId } from '@/lib/repos/anhaenge';
import { istBild } from '@/lib/anhaenge/dateien'; import { istBild } from '@/lib/anhaenge/dateien';
import { modulAn, modulGesperrt } from '@/lib/module/pruefen';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -18,6 +19,7 @@ export const dynamic = 'force-dynamic';
export async function GET(_anfrage: Request, kontext: { params: Promise<{ id: string }> }) { export async function GET(_anfrage: Request, kontext: { params: Promise<{ id: string }> }) {
const benutzer = await angemeldeterBenutzer(); const benutzer = await angemeldeterBenutzer();
if (!benutzer) return new Response('Nicht angemeldet.', { status: 401 }); if (!benutzer) return new Response('Nicht angemeldet.', { status: 401 });
if (!modulAn('aufgaben')) return modulGesperrt();
const { id } = await kontext.params; const { id } = await kontext.params;
const eintrag = anhangNachId(id); const eintrag = anhangNachId(id);
+4
View File
@@ -4,6 +4,7 @@ import { bewerte } from '@/lib/repos/karten';
import { bewertungen, type Bewertung } from '@/lib/lernen/sm2'; import { bewertungen, type Bewertung } from '@/lib/lernen/sm2';
import { heute } from '@/lib/zeit'; import { heute } from '@/lib/zeit';
import { tagDerBewertung } from '@/lib/offline/nachtrag'; import { tagDerBewertung } from '@/lib/offline/nachtrag';
import { modulAn } from '@/lib/module/pruefen';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -28,6 +29,9 @@ type Eingang = { karteId?: unknown; bewertung?: unknown; erfasstAm?: unknown };
export async function POST(anfrage: Request): Promise<Response> { export async function POST(anfrage: Request): Promise<Response> {
const benutzer = await angemeldeterBenutzer(); const benutzer = await angemeldeterBenutzer();
if (!benutzer) return Response.json({ fehler: 'Nicht angemeldet.' }, { status: 401 }); if (!benutzer) return Response.json({ fehler: 'Nicht angemeldet.' }, { status: 401 });
// 410 statt 404: Das Gerät soll seine gesammelten Bewertungen verwerfen,
// statt sie bei jeder Verbindung erneut zu schicken.
if (!modulAn('karten')) return Response.json({ fehler: 'Die Merkkarten sind abgeschaltet.' }, { status: 410 });
if (benutzer.rolle !== 'azubi') { if (benutzer.rolle !== 'azubi') {
return Response.json({ fehler: 'Nur der Azubi führt seinen eigenen Lernstand.' }, { status: 403 }); return Response.json({ fehler: 'Nur der Azubi führt seinen eigenen Lernstand.' }, { status: 403 });
} }
+15 -6
View File
@@ -1,4 +1,16 @@
import type { MetadataRoute } from 'next'; import type { MetadataRoute } from 'next';
import { modulStand } from '@/lib/repos/module';
import { pfadOffen } from '@/lib/module/module';
// Die Kurzbefehle richten sich nach den eingeschalteten Modulen – also bei
// jedem Abruf neu erzeugen, nicht einmal beim Bau.
export const dynamic = 'force-dynamic';
const kurzbefehle = [
{ name: 'Lernmodus', short_name: 'Lernen', url: '/karten/lernen' },
{ name: 'Berichtsheft', short_name: 'Bericht', url: '/berichtsheft' },
{ name: 'Handouts', short_name: 'Handouts', url: '/handouts' },
];
/** /**
* Die Angaben für die Installation auf dem Startbildschirm. * Die Angaben für die Installation auf dem Startbildschirm.
@@ -8,11 +20,12 @@ import type { MetadataRoute } from 'next';
* abgelaufen, leitet der Server ohnehin zur Anmeldung. * abgelaufen, leitet der Server ohnehin zur Anmeldung.
*/ */
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
const stand = modulStand();
return { return {
name: 'Lern-Bar – Betriebliche Ausbildung', name: 'Lern-Bar – Betriebliche Ausbildung',
short_name: 'Lern-Bar', short_name: 'Lern-Bar',
description: description:
'Wochenplan, Handouts, Merkkarten, Aufgaben, Checklisten und Berichtsheft für die Ausbildung zum Fachinformatiker für Systemintegration.', 'Wochenplan, Lehrgang und Nachweise für die Ausbildung zum Fachinformatiker für Systemintegration.',
lang: 'de', lang: 'de',
dir: 'ltr', dir: 'ltr',
start_url: '/', start_url: '/',
@@ -27,10 +40,6 @@ export default function manifest(): MetadataRoute.Manifest {
{ src: '/symbol-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, { src: '/symbol-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/symbol-maskabel-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, { src: '/symbol-maskabel-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
], ],
shortcuts: [ shortcuts: kurzbefehle.filter((kurz) => pfadOffen(kurz.url, stand)),
{ name: 'Lernmodus', short_name: 'Lernen', url: '/karten/lernen' },
{ name: 'Berichtsheft', short_name: 'Bericht', url: '/berichtsheft' },
{ name: 'Handouts', short_name: 'Handouts', url: '/handouts' },
],
}; };
} }
+9 -2
View File
@@ -7,6 +7,7 @@ import { berichteVon, fehlendeWochen } from '../repos/berichtsheft';
import { ergebnisVon, staendeVon as kontrollStaende } from '../repos/kontrollen'; import { ergebnisVon, staendeVon as kontrollStaende } from '../repos/kontrollen';
import { alsDatumText, alsZeitpunktText } from '../zeit'; import { alsDatumText, alsZeitpunktText } from '../zeit';
import { berechneAbdeckung, berechneLernfelder, type Nachweis } from './abdeckung'; import { berechneAbdeckung, berechneLernfelder, type Nachweis } from './abdeckung';
import { modulStand } from '../repos/module';
/** /**
* Sammelt aus Inhalten und Fortschritt alles, was die Auswertung braucht. * Sammelt aus Inhalten und Fortschritt alles, was die Auswertung braucht.
@@ -17,6 +18,9 @@ import { berechneAbdeckung, berechneLernfelder, type Nachweis } from './abdeckun
export function rahmenplanabdeckung(azubiId: string) { export function rahmenplanabdeckung(azubiId: string) {
const alle = inhalte(); const alle = inhalte();
// Abgeschaltete Module liefern keine Nachweise. Sonst stünden ihre Inhalte
// für immer als „geplant, noch nicht bearbeitet" da.
const eingeschaltet = modulStand();
const gelesen = gelesenSet(azubiId); const gelesen = gelesenSet(azubiId);
const aufgaben = aufgabenStaende(azubiId); const aufgaben = aufgabenStaende(azubiId);
const laeufe = laeufeVon(azubiId); const laeufe = laeufeVon(azubiId);
@@ -47,7 +51,7 @@ export function rahmenplanabdeckung(azubiId: string) {
} }
// --- Praxisaufgaben --- // --- Praxisaufgaben ---
for (const aufgabe of alle.aufgaben) { for (const aufgabe of eingeschaltet.aufgaben ? alle.aufgaben : []) {
const stand = aufgaben.get(aufgabe.id); const stand = aufgaben.get(aufgabe.id);
const nachweis: Nachweis = { const nachweis: Nachweis = {
art: 'aufgabe', art: 'aufgabe',
@@ -61,7 +65,7 @@ export function rahmenplanabdeckung(azubiId: string) {
} }
// --- Checklisten --- // --- Checklisten ---
for (const liste of alle.checklisten) { for (const liste of eingeschaltet.checklisten ? alle.checklisten : []) {
const abgeschlossenAm = abgeschlosseneListen.get(liste.id); const abgeschlossenAm = abgeschlosseneListen.get(liste.id);
const nachweis: Nachweis = { const nachweis: Nachweis = {
art: 'checkliste', art: 'checkliste',
@@ -75,6 +79,7 @@ export function rahmenplanabdeckung(azubiId: string) {
} }
return { return {
module: eingeschaltet,
positionen: berechneAbdeckung(alle.positionen, jePosition), positionen: berechneAbdeckung(alle.positionen, jePosition),
lernfelder: berechneLernfelder(alle.lernfelder, jeLernfeld), lernfelder: berechneLernfelder(alle.lernfelder, jeLernfeld),
gruppennamen: alle.rahmenplan.gruppen, gruppennamen: alle.rahmenplan.gruppen,
@@ -84,6 +89,7 @@ export function rahmenplanabdeckung(azubiId: string) {
/** Der persönliche Stand des Azubis – was er selbst sehen will. */ /** Der persönliche Stand des Azubis – was er selbst sehen will. */
export function eigenerStand(azubiId: string, beginn: string | null) { export function eigenerStand(azubiId: string, beginn: string | null) {
const alle = inhalte(); const alle = inhalte();
const eingeschaltet = modulStand();
const gelesen = gelesenSet(azubiId); const gelesen = gelesenSet(azubiId);
const aufgaben = aufgabenStaende(azubiId); const aufgaben = aufgabenStaende(azubiId);
const karten = kartenUebersicht(azubiId, alle.karten.map((k) => k.id)); const karten = kartenUebersicht(azubiId, alle.karten.map((k) => k.id));
@@ -111,6 +117,7 @@ export function eigenerStand(azubiId: string, beginn: string | null) {
.sort((a, b) => a.woche - b.woche); .sort((a, b) => a.woche - b.woche);
return { return {
module: eingeschaltet,
handouts: { erledigt: gelesen.size, gesamt: alle.handouts.length }, handouts: { erledigt: gelesen.size, gesamt: alle.handouts.length },
karten: { erledigt: karten.gelernt, gesamt: karten.gesamt, faellig: karten.faellig }, karten: { erledigt: karten.gelernt, gesamt: karten.gesamt, faellig: karten.faellig },
aufgaben: { aufgaben: {
+15
View File
@@ -0,0 +1,15 @@
-- Fassung 1.1: Module ein- und ausschalten.
--
-- Eine Zeile je Modul, das schon einmal umgeschaltet wurde. Fehlt die Zeile,
-- ist das Modul eingeschaltet – eine frische Datenbank hat alles an, und eine
-- bestehende verhält sich nach dem Einspielen genau wie vorher.
--
-- Abschalten löscht nichts. Die Daten eines Moduls bleiben in ihren Tabellen
-- und sind nach dem Wiedereinschalten unverändert da.
CREATE TABLE modul_einstellung (
modul TEXT PRIMARY KEY,
aktiv INTEGER NOT NULL CHECK (aktiv IN (0, 1)),
geaendert_von TEXT REFERENCES benutzer (id) ON DELETE SET NULL,
geaendert_am TEXT NOT NULL
);
+10
View File
@@ -323,6 +323,14 @@ export const feedbackgespraech = sqliteTable(
(t) => [index('feedbackgespraech_ausbildung_idx').on(t.ausbildungId, t.datum)], (t) => [index('feedbackgespraech_ausbildung_idx').on(t.ausbildungId, t.datum)],
); );
/** Abschaltbare Module – eine Zeile je Modul, das je umgeschaltet wurde. Siehe lib/module. */
export const modulEinstellung = sqliteTable('modul_einstellung', {
modul: text('modul').primaryKey(),
aktiv: integer('aktiv').notNull(),
geaendertVon: text('geaendert_von').references(() => benutzer.id, { onDelete: 'set null' }),
geaendertAm: text('geaendert_am').notNull(),
});
export type Benutzer = typeof benutzer.$inferSelect; export type Benutzer = typeof benutzer.$inferSelect;
export type Sitzung = typeof sitzung.$inferSelect; export type Sitzung = typeof sitzung.$inferSelect;
export type Ausbildung = typeof ausbildung.$inferSelect; export type Ausbildung = typeof ausbildung.$inferSelect;
@@ -339,6 +347,7 @@ export type Wochenbericht = typeof wochenbericht.$inferSelect;
export type Kontrolle = typeof kontrolle.$inferSelect; export type Kontrolle = typeof kontrolle.$inferSelect;
export type KontrollAntwort = typeof kontrollAntwort.$inferSelect; export type KontrollAntwort = typeof kontrollAntwort.$inferSelect;
export type Feedbackgespraech = typeof feedbackgespraech.$inferSelect; export type Feedbackgespraech = typeof feedbackgespraech.$inferSelect;
export type ModulEinstellung = typeof modulEinstellung.$inferSelect;
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */ /** Wird von der Schema-Abgleichprüfung im Test verwendet. */
export const alleTabellen = { export const alleTabellen = {
@@ -358,4 +367,5 @@ export const alleTabellen = {
kontrolle, kontrolle,
kontrollAntwort, kontrollAntwort,
feedbackgespraech, feedbackgespraech,
modulEinstellung,
}; };
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import {
abgeschaltet,
alleAn,
istModulId,
modulIds,
moduleintraege,
modulVonPfad,
nurOffene,
pfadOffen,
standAus,
} from './module';
describe('standAus', () => {
it('hat in einer frischen Datenbank alles an', () => {
expect(standAus([])).toEqual(alleAn);
expect(Object.values(standAus([])).every(Boolean)).toBe(true);
});
it('übernimmt gespeicherte Schalter', () => {
const stand = standAus([
{ modul: 'berichtsheft', aktiv: 0 },
{ modul: 'karten', aktiv: 1 },
]);
expect(stand.berichtsheft).toBe(false);
expect(stand.karten).toBe(true);
expect(stand.aufgaben).toBe(true);
});
it('übergeht unbekannte Kennungen, etwa aus einer späteren Fassung', () => {
const stand = standAus([{ modul: 'gibtsnicht', aktiv: 0 }]);
expect(stand).toEqual(alleAn);
expect('gibtsnicht' in stand).toBe(false);
});
});
describe('modulVonPfad', () => {
it('ordnet Seiten und Unterseiten ihrem Modul zu', () => {
expect(modulVonPfad('/berichtsheft')).toBe('berichtsheft');
expect(modulVonPfad('/berichtsheft/abc/pdf')).toBe('berichtsheft');
expect(modulVonPfad('/karten/lernen')).toBe('karten');
expect(modulVonPfad('/checklisten/lauf/123')).toBe('checklisten');
});
it('ordnet Routen außerhalb der Seiten ihrem Modul zu', () => {
expect(modulVonPfad('/anhaenge/abc')).toBe('aufgaben');
expect(modulVonPfad('/api/lernstand')).toBe('karten');
});
it('lässt das Gerüst unangetastet', () => {
for (const pfad of ['/', '/wochenplan', '/woche/3', '/handouts', '/handouts/N3', '/glossar', '/auswertung', '/verwaltung', '/verwaltung/module']) {
expect(modulVonPfad(pfad), pfad).toBeNull();
}
});
it('trifft nur ganze Pfadteile', () => {
// „/kartenspiel" gehört nicht zu den Merkkarten.
expect(modulVonPfad('/kartenspiel')).toBeNull();
expect(modulVonPfad('/feedbackbogen')).toBeNull();
});
});
describe('pfadOffen und nurOffene', () => {
const navigation = [
{ pfad: '/', titel: 'Woche' },
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/berichtsheft', titel: 'Berichtsheft' },
{ pfad: '/glossar', titel: 'Glossar' },
];
it('lässt bei allem an die Navigation unverändert', () => {
expect(nurOffene(navigation, alleAn)).toEqual(navigation);
});
it('entfernt genau die abgeschalteten Module', () => {
const stand = { ...alleAn, berichtsheft: false };
expect(nurOffene(navigation, stand).map((e) => e.titel)).toEqual(['Woche', 'Merkkarten', 'Glossar']);
expect(pfadOffen('/berichtsheft/pdf', stand)).toBe(false);
expect(pfadOffen('/karten/lernen', stand)).toBe(true);
});
it('behält das Gerüst auch, wenn alles aus ist', () => {
const alleAus = Object.fromEntries(modulIds.map((id) => [id, false])) as typeof alleAn;
expect(nurOffene(navigation, alleAus).map((e) => e.titel)).toEqual(['Woche', 'Glossar']);
expect(abgeschaltet(alleAus)).toEqual([...modulIds]);
});
});
describe('Moduleinträge', () => {
it('beschreibt jedes Modul vollständig', () => {
for (const id of modulIds) {
const eintrag = moduleintraege[id];
expect(eintrag.id).toBe(id);
expect(eintrag.titel.length, id).toBeGreaterThan(0);
expect(eintrag.beschreibung.length, id).toBeGreaterThan(10);
expect(eintrag.folgen.length, id).toBeGreaterThan(10);
expect(eintrag.pfade.length, id).toBeGreaterThan(0);
}
});
it('erkennt gültige Kennungen', () => {
expect(istModulId('karten')).toBe(true);
expect(istModulId('verwaltung')).toBe(false);
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* Die abschaltbaren Module der Lern-Bar.
*
* Bewusst ohne Datenbank und ohne Next.js – die Liste und die Regeln stehen
* hier, der gespeicherte Zustand kommt aus `repos/module`, die Durchsetzung aus
* `module/pruefen`. So lassen sich die Regeln vollständig testen.
*
* Immer an bleiben Wochenplan, Handouts, Glossar, Auswertung und Verwaltung:
* Sie sind das Gerüst, an dem alles andere hängt. Die Auswertung passt sich an
* und zeigt nur, was eingeschaltet ist.
*
* Abschalten heißt ausblenden und sperren, **nicht löschen**. Was ein Azubi in
* einem Modul geschrieben hat, bleibt in der Datenbank und ist nach dem
* Wiedereinschalten unverändert da. Siehe E43.
*/
export const modulIds = ['karten', 'aufgaben', 'checklisten', 'berichtsheft', 'kontrollen', 'feedback'] as const;
export type ModulId = (typeof modulIds)[number];
/** Für jedes Modul: an oder aus. */
export type ModulStand = Record<ModulId, boolean>;
export type Moduleintrag = {
id: ModulId;
titel: string;
/** Was das Modul tut – für die Verwaltung. */
beschreibung: string;
/** Was sich ändert, wenn es aus ist – damit niemand überrascht wird. */
folgen: string;
/** Die Pfade, die zu diesem Modul gehören und mit ihm gesperrt werden. */
pfade: string[];
};
export const moduleintraege: Record<ModulId, Moduleintrag> = {
karten: {
id: 'karten',
titel: 'Merkkarten',
beschreibung: 'Wiederholung nach Zeitabstand, täglicher Stapel, Lernen auch ohne Netz.',
folgen:
'Merkkarten verschwinden aus Handouts, Wochenfortschritt und „Mein Stand". Bewertungen, die ein Gerät noch ohne Netz gesammelt hat, werden verworfen.',
pfade: ['/karten', '/api/lernstand'],
},
aufgaben: {
id: 'aufgaben',
titel: 'Praxisaufgaben',
beschreibung: 'Aufträge mit Bewertungskriterien, Notizen und Anhängen, Abnahme mit Note.',
folgen:
'Aufgaben verschwinden aus Wochenplan, „Was auf dich wartet" und Auswertung. In der Rahmenplanabdeckung zählen sie nicht mehr; Anhänge sind nicht mehr abrufbar.',
pfade: ['/aufgaben', '/anhaenge'],
},
checklisten: {
id: 'checklisten',
titel: 'Checklisten',
beschreibung: 'Abläufe Schritt für Schritt abhaken, Durchläufe als Nachweis mit PDF.',
folgen:
'Checklisten verschwinden aus Wochenplan und Wochenfortschritt. In der Rahmenplanabdeckung und bei den Vorschlägen fürs Berichtsheft zählen sie nicht mehr.',
pfade: ['/checklisten'],
},
berichtsheft: {
id: 'berichtsheft',
titel: 'Berichtsheft',
beschreibung: 'Tagesnotizen, Wochenblätter mit Vorschlägen, Einreichen, Abzeichnen und PDF im IHK-Layout.',
folgen:
'Das Berichtsheft verschwindet aus Navigation, „Was auf dich wartet" und „Mein Stand". Den Ausbildungsnachweis muss der Azubi dann anderswo führen – auf Papier oder im Portal der Kammer.',
pfade: ['/berichtsheft'],
},
kontrollen: {
id: 'kontrollen',
titel: 'Lernzielkontrollen',
beschreibung: 'Freigabe durch den Ausbilder, Abgabe, Korrektur mit Punkten und IHK-Notenschlüssel.',
folgen:
'Kontrollen verschwinden aus Wochenplan, Wochenfortschritt, „Was auf dich wartet" und dem Ergebnisverlauf der Auswertung.',
pfade: ['/kontrollen'],
},
feedback: {
id: 'feedback',
titel: 'Feedbackgespräch',
beschreibung: 'Leitfaden und Bewertungsbogen, getrennte Einschätzung beider Seiten, PDF für die Akte.',
folgen: 'Der Bereich „Feedback" verschwindet aus der Navigation. Bereits geführte Gespräche bleiben gespeichert.',
pfade: ['/feedback'],
},
};
/** Werkszustand: alles an. */
export const alleAn: ModulStand = Object.fromEntries(modulIds.map((id) => [id, true])) as ModulStand;
export function istModulId(wert: string): wert is ModulId {
return (modulIds as readonly string[]).includes(wert);
}
/**
* Baut den Stand aus gespeicherten Zeilen. Fehlt ein Modul, gilt es als an –
* eine frische Datenbank hat alles eingeschaltet. Unbekannte Kennungen (etwa
* aus einer späteren Fassung) werden übergangen.
*/
export function standAus(zeilen: { modul: string; aktiv: number }[]): ModulStand {
const stand: ModulStand = { ...alleAn };
for (const zeile of zeilen) {
if (istModulId(zeile.modul)) stand[zeile.modul] = zeile.aktiv === 1;
}
return stand;
}
/** Zu welchem Modul gehört ein Pfad? `null`, wenn zu keinem abschaltbaren. */
export function modulVonPfad(pfad: string): ModulId | null {
for (const id of modulIds) {
for (const wurzel of moduleintraege[id].pfade) {
if (pfad === wurzel || pfad.startsWith(`${wurzel}/`)) return id;
}
}
return null;
}
/** Ist ein Pfad bei diesem Stand erreichbar? */
export function pfadOffen(pfad: string, stand: ModulStand): boolean {
const modul = modulVonPfad(pfad);
return modul === null || stand[modul];
}
/** Lässt aus einer Liste von Zielen nur die erreichbaren übrig – für Navigation und Kurzbefehle. */
export function nurOffene<T extends { pfad: string }>(eintraege: T[], stand: ModulStand): T[] {
return eintraege.filter((eintrag) => pfadOffen(eintrag.pfad, stand));
}
/** Die abgeschalteten Module, in fester Reihenfolge. */
export function abgeschaltet(stand: ModulStand): ModulId[] {
return modulIds.filter((id) => !stand[id]);
}
+40
View File
@@ -0,0 +1,40 @@
import 'server-only';
import { notFound } from 'next/navigation';
import { modulStand } from '../repos/module';
import { moduleintraege, type ModulId } from './module';
/**
* Die Durchsetzung der Modulschalter. Ein abgeschaltetes Modul ist nicht nur
* unsichtbar, sondern zu – an drei Stellen:
*
* - **Seiten** rufen `erfordereModul` auf und enden mit „nicht gefunden",
* genau wie bei fehlender Berechtigung (E21).
* - **Routen** (PDF, Anhänge, Lernstand) fragen `modulAn` und antworten selbst.
* - **Serveraktionen** rufen `pruefeModul` auf. Ein Formular, das noch in einem
* offenen Fenster steht, darf nach dem Abschalten nichts mehr schreiben.
*
* Bewusst nicht im Layout des Moduls: Next.js rendert Layouts beim Wechsel
* zwischen Seiten darunter nicht neu, eine Prüfung dort würde also nicht bei
* jedem Aufruf greifen.
*/
export function modulAn(modul: ModulId): boolean {
return modulStand()[modul];
}
/** Für Seiten: abgeschaltet heißt „nicht gefunden". */
export function erfordereModul(modul: ModulId): void {
if (!modulAn(modul)) notFound();
}
/** Für Serveraktionen: abgeschaltet heißt abbrechen, bevor etwas geschrieben wird. */
export function pruefeModul(modul: ModulId): void {
if (!modulAn(modul)) {
throw new Error(`Das Modul „${moduleintraege[modul].titel}" ist abgeschaltet.`);
}
}
/** Für Routen: die Antwort, wenn das Modul aus ist. */
export function modulGesperrt(): Response {
return new Response('Nicht gefunden.', { status: 404 });
}
+6
View File
@@ -100,6 +100,12 @@ export async function warteschlangeAbschicken(benutzerId: string): Promise<numbe
credentials: 'same-origin', credentials: 'same-origin',
}); });
// Das Modul Merkkarten ist abgeschaltet: Die Bewertungen werden nie mehr
// angenommen, also nicht ewig weiter mitschleppen.
if (antwort.status === 410) {
warteschlangeLeeren(benutzerId);
return 0;
}
if (!antwort.ok) return null; if (!antwort.ok) return null;
const ergebnis: unknown = await antwort.json(); const ergebnis: unknown = await antwort.json();
+19 -6
View File
@@ -7,6 +7,8 @@ import { anzahlAbgenommen } from '../repos/aufgaben';
import { anzahlAbgeschlossen } from '../repos/checklisten'; import { anzahlAbgeschlossen } from '../repos/checklisten';
import { anzahlAbgegeben } from '../repos/kontrollen'; import { anzahlAbgegeben } from '../repos/kontrollen';
import { heute } from '../zeit'; import { heute } from '../zeit';
import { modulStand } from '../repos/module';
import type { ModulStand } from '../module/module';
import { aktuelleWoche } from './wochen'; import { aktuelleWoche } from './wochen';
import { wochenFortschritt, type Fortschritt } from './fortschritt'; import { wochenFortschritt, type Fortschritt } from './fortschritt';
import type { Wochenplanung } from '../db/schema'; import type { Wochenplanung } from '../db/schema';
@@ -34,6 +36,8 @@ export type Wochensicht = {
aufgaben: Aufgabe[]; aufgaben: Aufgabe[];
checklisten: Checkliste[]; checklisten: Checkliste[];
kontrolle: Kontrolle | null; kontrolle: Kontrolle | null;
/** Welche Module an sind – die Ansicht blendet die übrigen aus. */
module: ModulStand;
vorherige: number | null; vorherige: number | null;
naechste: number | null; naechste: number | null;
}; };
@@ -66,14 +70,22 @@ export function wochensicht(ausbildungId: string, azubiId: string, wochenNummer:
}; };
}); });
const aufgaben = woche.aufgaben.map((id) => alle.aufgabeNach.get(id)).filter((a) => a !== undefined); // Ein abgeschaltetes Modul trägt nichts zur Woche bei – weder in der Ansicht
const checklisten = woche.checklisten.map((id) => alle.checklisteNach.get(id)).filter((c) => c !== undefined); // noch im Fortschritt. So erreicht eine Woche auch ohne Merkkarten 100 %.
const kontrolle = woche.lernzielkontrolle ? (alle.kontrolleNach.get(woche.lernzielkontrolle) ?? null) : null; const eingeschaltet = modulStand();
const aufgaben = eingeschaltet.aufgaben
? woche.aufgaben.map((id) => alle.aufgabeNach.get(id)).filter((a) => a !== undefined)
: [];
const checklisten = eingeschaltet.checklisten
? woche.checklisten.map((id) => alle.checklisteNach.get(id)).filter((c) => c !== undefined)
: [];
const kontrolle =
eingeschaltet.kontrollen && woche.lernzielkontrolle ? (alle.kontrolleNach.get(woche.lernzielkontrolle) ?? null) : null;
// Die Merkkarten dieser Woche sind die Karten ihrer Handouts. // Die Merkkarten dieser Woche sind die Karten ihrer Handouts.
const karteIds = handouts.flatMap(({ handout }) => const karteIds = eingeschaltet.karten
alle.karten.filter((k) => k.handoutId === handout.id).map((k) => k.id), ? handouts.flatMap(({ handout }) => alle.karten.filter((k) => k.handoutId === handout.id).map((k) => k.id))
); : [];
const kartenStand = kartenUebersicht(azubiId, karteIds, heuteDatum); const kartenStand = kartenUebersicht(azubiId, karteIds, heuteDatum);
const fortschritt = wochenFortschritt({ const fortschritt = wochenFortschritt({
@@ -102,6 +114,7 @@ export function wochensicht(ausbildungId: string, azubiId: string, wochenNummer:
aufgaben, aufgaben,
checklisten, checklisten,
kontrolle, kontrolle,
module: eingeschaltet,
vorherige: stelle > 0 ? (nummern[stelle - 1] ?? null) : null, vorherige: stelle > 0 ? (nummern[stelle - 1] ?? null) : null,
naechste: stelle < nummern.length - 1 ? (nummern[stelle + 1] ?? null) : null, naechste: stelle < nummern.length - 1 ? (nummern[stelle + 1] ?? null) : null,
}; };
+28
View File
@@ -0,0 +1,28 @@
import { datenbank, schema } from '../db';
import { jetzt } from '../zeit';
import { standAus, type ModulId, type ModulStand } from '../module/module';
/** Sämtlicher Zugriff auf die Tabelle `modul_einstellung` läuft über dieses Modul. */
/** Der aktuelle Stand aller Module. Ohne gespeicherte Zeile ist ein Modul an. */
export function modulStand(): ModulStand {
const zeilen = datenbank()
.select({ modul: schema.modulEinstellung.modul, aktiv: schema.modulEinstellung.aktiv })
.from(schema.modulEinstellung)
.all();
return standAus(zeilen);
}
/** Wer wann zuletzt umgeschaltet hat – für die Anzeige in der Verwaltung. */
export function modulAenderungen() {
return datenbank().select().from(schema.modulEinstellung).all();
}
export function modulSetzen(modul: ModulId, aktiv: boolean, benutzerId: string): void {
const werte = { aktiv: aktiv ? 1 : 0, geaendertVon: benutzerId, geaendertAm: jetzt() };
datenbank()
.insert(schema.modulEinstellung)
.values({ modul, ...werte })
.onConflictDoUpdate({ target: schema.modulEinstellung.modul, set: werte })
.run();
}