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>
This commit is contained in:
Florian Wilde
2026-10-05 16:58:32 +02:00
co-authored by Claude Opus 5.5
parent 54ce7d87ae
commit 44e6c8bb79
52 changed files with 993 additions and 158 deletions
+36
View File
@@ -26,6 +26,42 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
## [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
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
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.
## 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
Gesichert werden **Datenbank und Anhänge**. Die Inhalte stecken im Repository
@@ -244,7 +259,7 @@ Protokollzeilen.
```bash
npm run typecheck # TypeScript
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: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
5. Der Ausbilder nimmt sie ab; sie schlägt in der Rahmenplanabdeckung durch
6. Das Wochenblatt wird angelegt und als PDF ausgegeben
7. **Gesichert, absichtlich beschädigt, zurückgespielt.** Eine Sicherung, die
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
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
(`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
+16 -6
View File
@@ -1,6 +1,6 @@
# 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**.
@@ -54,15 +54,25 @@ Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenba
- ✓ Der Azubi legt das Wochenblatt der laufenden Woche an
- ✓ 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
- ✓ Die Rückspielung läuft durch — ohne Fehler
- ✓ Der gelöschte Azubi ist wieder da — Tim Vogt
- ✓ Auch der Lernverlauf kam mit zurück — 1 Einträge
- ✓ Der überschriebene Stand wurde zur Seite gelegt, nicht gelöscht — verdraengt-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)
7. [Die Rahmenplanabdeckung lesen](#7-die-rahmenplanabdeckung-lesen)
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**
9. [Die Woche](#9-die-woche)
10. [Handouts lesen und suchen](#10-handouts-lesen-und-suchen)
11. [Merkkarten lernen](#11-merkkarten-lernen)
12. [Eine Praxisaufgabe abgeben](#12-eine-praxisaufgabe-abgeben)
13. [Eine Checkliste abarbeiten](#13-eine-checkliste-abarbeiten)
14. [Das Berichtsheft führen](#14-das-berichtsheft-führen)
15. [Eine Lernzielkontrolle schreiben](#15-eine-lernzielkontrolle-schreiben)
10. [Die Woche](#10-die-woche)
11. [Handouts lesen und suchen](#11-handouts-lesen-und-suchen)
12. [Merkkarten lernen](#12-merkkarten-lernen)
13. [Eine Praxisaufgabe abgeben](#13-eine-praxisaufgabe-abgeben)
14. [Eine Checkliste abarbeiten](#14-eine-checkliste-abarbeiten)
15. [Das Berichtsheft führen](#15-das-berichtsheft-führen)
16. [Eine Lernzielkontrolle schreiben](#16-eine-lernzielkontrolle-schreiben)
**Überall**
16. [Auf dem Handy](#16-auf-dem-handy)
17. [Ohne Netz](#17-ohne-netz)
18. [Hell und dunkel](#18-hell-und-dunkel)
19. [Drucken](#19-drucken)
20. [Wenn etwas nicht geht](#20-wenn-etwas-nicht-geht)
17. [Auf dem Handy](#17-auf-dem-handy)
18. [Ohne Netz](#18-ohne-netz)
19. [Hell und dunkel](#19-hell-und-dunkel)
20. [Drucken](#20-drucken)
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
> 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
### 9. Die Woche
### 10. Die Woche
![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)
@@ -254,7 +293,7 @@ mit Querverweisen.
---
### 11. Merkkarten lernen
### 12. Merkkarten lernen
![Merkkarten](bilder/14-merkkarten.png)
@@ -273,7 +312,7 @@ nachzuholen.
---
### 12. Eine Praxisaufgabe abgeben
### 13. Eine Praxisaufgabe abgeben
![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)
@@ -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)
@@ -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
schreibst deine Antworten und sendest ab.
@@ -336,7 +375,7 @@ Kontrolle, deren Lösung vorher abrufbar wäre, misst nichts.
## Ü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
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:
@@ -376,7 +415,7 @@ weggeräumt.
---
### 18. Hell und dunkel
### 19. Hell und dunkel
![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**:
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 |
|---|---|
| „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". |
| 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). |
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

+54 -2
View File
@@ -404,6 +404,7 @@ try {
);
const blatt = await z.$('.blattzeile-link');
let blattAdresse = null;
if (blatt) {
await blatt.click();
// 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.
await z.waitForURL(/\/berichtsheft\/[0-9a-f-]{36}$/, { timeout: 30000 });
pruefe(true, 'Ein Wochenblatt lässt sich öffnen');
blattAdresse = z.url();
const pdf = await azubi.request.get(`${z.url()}/pdf`);
const puffer = await pdf.body();
@@ -425,9 +427,59 @@ try {
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.
const alsPosix = (pfad) => pfad.split(path.sep).join('/');
+12
View File
@@ -74,6 +74,18 @@ async function main() {
await seite.waitForSelector('#neu-name');
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.waitForSelector('h1');
await bild(seite, '04-wochenplan');
+56 -50
View File
@@ -155,58 +155,64 @@ export function WochenAnsicht({
)}
</section>
<section className="karte" aria-labelledby="woche-aufgaben">
<h2 id="woche-aufgaben" style={{ fontSize: '1rem' }}>
Praxisaufgaben
</h2>
{sicht.aufgaben.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{sicht.aufgaben.map((aufgabe) => (
<li key={aufgabe.id}>
<strong>{aufgabe.id}</strong> {aufgabe.titel}
<br />
<span className="leise">{aufgabe.zeitrahmen}</span>
</li>
))}
</ul>
)}
</section>
{sicht.module.aufgaben ? (
<section className="karte" aria-labelledby="woche-aufgaben">
<h2 id="woche-aufgaben" style={{ fontSize: '1rem' }}>
Praxisaufgaben
</h2>
{sicht.aufgaben.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{sicht.aufgaben.map((aufgabe) => (
<li key={aufgabe.id}>
<strong>{aufgabe.id}</strong> {aufgabe.titel}
<br />
<span className="leise">{aufgabe.zeitrahmen}</span>
</li>
))}
</ul>
)}
</section>
) : null}
<section className="karte" aria-labelledby="woche-checklisten">
<h2 id="woche-checklisten" style={{ fontSize: '1rem' }}>
Checklisten
</h2>
{sicht.checklisten.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{sicht.checklisten.map((liste) => (
<li key={liste.id}>{liste.titel}</li>
))}
</ul>
)}
</section>
{sicht.module.checklisten ? (
<section className="karte" aria-labelledby="woche-checklisten">
<h2 id="woche-checklisten" style={{ fontSize: '1rem' }}>
Checklisten
</h2>
{sicht.checklisten.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{sicht.checklisten.map((liste) => (
<li key={liste.id}>{liste.titel}</li>
))}
</ul>
)}
</section>
) : null}
<section className="karte" aria-labelledby="woche-kontrolle">
<h2 id="woche-kontrolle" style={{ fontSize: '1rem' }}>
Lernzielkontrolle
</h2>
{sicht.kontrolle ? (
<p style={{ marginBottom: 0 }}>
{sicht.kontrolle.titel}
<br />
<span className="leise">
{sicht.kontrolle.fragen.length} Fragen ·{' '}
{sicht.kontrolle.fragen.reduce((summe, f) => summe + f.punkte, 0)} Punkte
{sicht.kontrolle.dauer_minuten ? ` · ${sicht.kontrolle.dauer_minuten} Minuten` : ''}
</span>
</p>
) : (
<p className="leise" style={{ marginBottom: 0 }}>Diese Woche keine.</p>
)}
</section>
{sicht.module.kontrollen ? (
<section className="karte" aria-labelledby="woche-kontrolle">
<h2 id="woche-kontrolle" style={{ fontSize: '1rem' }}>
Lernzielkontrolle
</h2>
{sicht.kontrolle ? (
<p style={{ marginBottom: 0 }}>
{sicht.kontrolle.titel}
<br />
<span className="leise">
{sicht.kontrolle.fragen.length} Fragen ·{' '}
{sicht.kontrolle.fragen.reduce((summe, f) => summe + f.punkte, 0)} Punkte
{sicht.kontrolle.dauer_minuten ? ` · ${sicht.kontrolle.dauer_minuten} Minuten` : ''}
</span>
</p>
) : (
<p className="leise" style={{ marginBottom: 0 }}>Diese Woche keine.</p>
)}
</section>
) : null}
</div>
<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 { AnhangFormular, BewertungsFormular, NotizFormular, StatusWaehlen } from './Formulare';
import { anhangEntfernen, aufgabeAbnehmen, aufgabeZurueckgeben } from '../aktionen';
import { erfordereModul } from '@/lib/module/pruefen';
export const dynamic = 'force-dynamic';
@@ -29,6 +30,7 @@ export default async function AufgabenSeite({
searchParams: Promise<{ azubi?: string }>;
}) {
const benutzer = await erfordereBenutzer();
erfordereModul('aufgaben');
const { id } = await params;
const { azubi: azubiParameter } = await searchParams;
@@ -13,6 +13,7 @@ import {
zurueckgeben,
} from '@/lib/repos/aufgaben';
import { aufgabenStatus, type AufgabenStatus } from '@/lib/db/schema';
import { pruefeModul } from '@/lib/module/pruefen';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
@@ -30,6 +31,7 @@ function aufgabeGibtEs(aufgabeId: string): boolean {
export async function eigenenStatusSetzen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
pruefeModul('aufgaben');
if (benutzer.rolle !== 'azubi') return;
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> {
const benutzer = await erfordereBenutzer();
pruefeModul('aufgaben');
if (benutzer.rolle !== 'azubi') return { fehler: 'Nur der Azubi schreibt seine eigenen Notizen.' };
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> {
const benutzer = await erfordereBenutzer();
pruefeModul('aufgaben');
if (benutzer.rolle !== 'azubi') return { fehler: 'Anhänge lädt der Azubi hoch.' };
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> {
const benutzer = await erfordereBenutzer();
pruefeModul('aufgaben');
const anhangId = String(formular.get('anhangId') ?? '');
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> {
const ausbilder = await erfordereBenutzer();
pruefeModul('aufgaben');
const azubiId = String(formular.get('azubiId') ?? '');
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> {
const ausbilder = await erfordereBenutzer();
pruefeModul('aufgaben');
const azubiId = String(formular.get('azubiId') ?? '');
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> {
const ausbilder = await erfordereBenutzer();
pruefeModul('aufgaben');
const azubiId = String(formular.get('azubiId') ?? '');
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. */
export async function pruefeLesezugriff(azubiId: string): Promise<void> {
const benutzer = await erfordereBenutzer();
pruefeModul('aufgaben');
erfordereZugriffAufAzubi(benutzer, azubiId);
}
+2
View File
@@ -6,12 +6,14 @@ import { inhalte } from '@/lib/inhalte';
import { staendeVon } from '@/lib/repos/aufgaben';
import { alsZeitpunktText } from '@/lib/zeit';
import { StatusMarke } from './StatusMarke';
import { erfordereModul } from '@/lib/module/pruefen';
export const metadata: Metadata = { title: 'Praxisaufgaben' };
export const dynamic = 'force-dynamic';
export default async function AufgabenListe() {
const benutzer = await erfordereBenutzer();
erfordereModul('aufgaben');
const alle = inhalte();
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)}
fuss={`${stand.handouts.gesamt - stand.handouts.erledigt} noch offen`}
/>
<Kachel
titel="Merkkarten, die sitzen"
wert={`${stand.karten.erledigt} von ${stand.karten.gesamt}`}
anteil={anteil(stand.karten.erledigt, stand.karten.gesamt)}
fuss={stand.karten.faellig > 0 ? `${stand.karten.faellig} heute fällig` : 'heute nichts fällig'}
/>
<Kachel
titel="Praxisaufgaben abgenommen"
wert={`${stand.aufgaben.erledigt} von ${stand.aufgaben.gesamt}`}
anteil={anteil(stand.aufgaben.erledigt, stand.aufgaben.gesamt)}
fuss={
stand.aufgaben.zurAbnahme > 0
? `${stand.aufgaben.zurAbnahme} wartet auf Abnahme`
: `${stand.aufgaben.offen} noch offen`
}
/>
<Kachel
titel="Berichtsheft abgezeichnet"
wert={`${stand.berichtsheft.abgezeichnet} von ${stand.berichtsheft.gesamt}`}
anteil={anteil(stand.berichtsheft.abgezeichnet, stand.berichtsheft.gesamt)}
fuss={
stand.berichtsheft.fehlendeWochen > 0
? `${stand.berichtsheft.fehlendeWochen} Wochen ohne Blatt`
: stand.berichtsheft.eingereicht > 0
? `${stand.berichtsheft.eingereicht} beim Ausbilder`
: 'nichts offen'
}
ton={stand.berichtsheft.fehlendeWochen > 0 ? 'fehler' : undefined}
/>
{stand.module.karten ? (
<Kachel
titel="Merkkarten, die sitzen"
wert={`${stand.karten.erledigt} von ${stand.karten.gesamt}`}
anteil={anteil(stand.karten.erledigt, stand.karten.gesamt)}
fuss={stand.karten.faellig > 0 ? `${stand.karten.faellig} heute fällig` : 'heute nichts fällig'}
/>
) : null}
{stand.module.aufgaben ? (
<Kachel
titel="Praxisaufgaben abgenommen"
wert={`${stand.aufgaben.erledigt} von ${stand.aufgaben.gesamt}`}
anteil={anteil(stand.aufgaben.erledigt, stand.aufgaben.gesamt)}
fuss={
stand.aufgaben.zurAbnahme > 0
? `${stand.aufgaben.zurAbnahme} wartet auf Abnahme`
: `${stand.aufgaben.offen} noch offen`
}
/>
) : null}
{stand.module.berichtsheft ? (
<Kachel
titel="Berichtsheft abgezeichnet"
wert={`${stand.berichtsheft.abgezeichnet} von ${stand.berichtsheft.gesamt}`}
anteil={anteil(stand.berichtsheft.abgezeichnet, stand.berichtsheft.gesamt)}
fuss={
stand.berichtsheft.fehlendeWochen > 0
? `${stand.berichtsheft.fehlendeWochen} Wochen ohne Blatt`
: stand.berichtsheft.eingereicht > 0
? `${stand.berichtsheft.eingereicht} beim Ausbilder`
: 'nichts offen'
}
ton={stand.berichtsheft.fehlendeWochen > 0 ? 'fehler' : undefined}
/>
) : null}
</div>
{stand.berichtsheft.fehlendeWochen > 0 ? (
{stand.module.berichtsheft && stand.berichtsheft.fehlendeWochen > 0 ? (
<p className="hinweis hinweis-warnung">
<strong>
{stand.berichtsheft.fehlendeWochen}{' '}
@@ -145,7 +151,7 @@ function AzubiAuswertung({ azubiId, name }: { azubiId: string; name: string }) {
</p>
) : null}
<ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} />
{stand.module.kontrollen ? <ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} /> : null}
</div>
);
}
@@ -180,9 +186,14 @@ function Kachel({
// --- Rahmenplanabdeckung ---------------------------------------------------
function Rahmenplan({ azubiId }: { azubiId: string }) {
const { positionen, gruppennamen } = rahmenplanabdeckung(azubiId);
const { positionen, gruppennamen, module: eingeschaltet } = rahmenplanabdeckung(azubiId);
const gruppen = nachGruppen(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 (
<section className="stapel" aria-labelledby="rahmenplan">
@@ -192,8 +203,9 @@ function Rahmenplan({ azubiId }: { azubiId: string }) {
</h2>
<p className="leise" style={{ margin: 0 }}>
Die Ansicht, die im Zweifel gegenüber der IHK zählt. Als <strong>vermittelt</strong> gilt eine
Position, wenn es dazu eine abgenommene Praxisaufgabe oder einen abgeschlossenen
Checklisten-Durchlauf gibt – oder mindestens zwei gelesene Handouts.
Position, wenn es dazu{' '}
{belastbar.length > 0 ? `${belastbar.join(' oder ')} gibt – oder ` : ''}mindestens zwei gelesene
Handouts{belastbar.length > 0 ? '' : ' gibt'}.
</p>
</div>
@@ -339,6 +351,7 @@ function Lernfelder({ azubiId }: { azubiId: string }) {
function Kontrollverlauf({ azubiId }: { azubiId: string }) {
const ausbildung = ausbildungNachAzubi(azubiId);
const stand = eigenerStand(azubiId, ausbildung?.beginn ?? null);
if (!stand.module.kontrollen) return null;
return <ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} />;
}
@@ -17,6 +17,9 @@ import { Formulierungshilfe } from '../Formulierungshilfe';
import { BerichtsMarke } from '../BerichtsMarke';
import { Abzeichnen } from './Abzeichnen';
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';
@@ -27,6 +30,8 @@ export async function generateMetadata({ params }: { params: Promise<{ berichtId
}
export default async function BlattSeite({ params }: { params: Promise<{ berichtId: string }> }) {
await erfordereBenutzer();
erfordereModul('berichtsheft');
const { berichtId } = await params;
// 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ß. */
function sammleVorschlaege(azubiId: string, von: string, bis: string) {
const alle = inhalte();
// Aus abgeschalteten Modulen kommen keine Vorschläge.
const eingeschaltet = modulStand();
const notizen = notizenIm(azubiId, von, bis).map((n) => ({
datum: n.datum,
@@ -247,7 +254,7 @@ function sammleVorschlaege(azubiId: string, von: string, bis: string) {
// Praxisaufgaben, die in dieser Woche abgegeben oder abgenommen wurden.
const staende = aufgabenStaende(azubiId);
const aufgaben = [...staende.values()]
const aufgaben = [...(eingeschaltet.aufgaben ? staende.values() : [])]
.filter((s) => {
const zeitpunkt = (s.abgenommenAm ?? s.abgegebenAm ?? '').slice(0, 10);
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)
.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 { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
import { blattZumLesen } from '../../aktionen';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { modulAn, modulGesperrt } from '@/lib/module/pruefen';
export const dynamic = 'force-dynamic';
/** Ein einzelnes Wochenblatt als PDF. Rechteprüfung über `blattZumLesen`. */
export async function GET(_anfrage: Request, kontext: { params: Promise<{ berichtId: string }> }) {
await erfordereBenutzer();
if (!modulAn('berichtsheft')) return modulGesperrt();
const { berichtId } = await kontext.params;
const gelesen = await blattZumLesen(berichtId);
@@ -14,6 +14,7 @@ import {
zurueckgeben,
} from '@/lib/repos/berichtsheft';
import type { Wochenblatt } from '@/lib/berichtsheft/pruefung';
import { pruefeModul } from '@/lib/module/pruefen';
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. */
export async function blattZumLesen(berichtId: string) {
const benutzer = await erfordereBenutzer();
pruefeModul('berichtsheft');
const bericht = berichtNachId(berichtId);
if (!bericht) return null;
@@ -50,6 +52,7 @@ function aktualisiere(berichtId?: string) {
export async function tagesnotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
pruefeModul('berichtsheft');
if (benutzer.rolle !== 'azubi') return { fehler: 'Tagesnotizen schreibt der Azubi.' };
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> {
const benutzer = await erfordereBenutzer();
pruefeModul('berichtsheft');
if (benutzer.rolle !== 'azubi') return;
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> {
pruefeModul('berichtsheft');
const berichtId = String(formular.get('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.
*/
export async function blattEinreichen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
pruefeModul('berichtsheft');
const berichtId = String(formular.get('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> {
pruefeModul('berichtsheft');
const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId);
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> {
const ausbilder = await erfordereBenutzer();
pruefeModul('berichtsheft');
const berichtId = String(formular.get('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> {
const ausbilder = await erfordereBenutzer();
pruefeModul('berichtsheft');
const berichtId = String(formular.get('berichtId') ?? '');
const bericht = berichtNachId(berichtId);
@@ -9,12 +9,14 @@ import { Tagesnotizen, type Tagesfeld } from './Tagesnotizen';
import { Formulierungshilfe } from './Formulierungshilfe';
import { blattAnlegen } from './aktionen';
import { BerichtsMarke } from './BerichtsMarke';
import { erfordereModul } from '@/lib/module/pruefen';
export const metadata: Metadata = { title: 'Berichtsheft' };
export const dynamic = 'force-dynamic';
export default async function BerichtsheftSeite() {
const benutzer = await erfordereBenutzer();
erfordereModul('berichtsheft');
return benutzer.rolle === 'ausbilder' ? (
<AusbilderSicht ausbilderId={benutzer.id} />
@@ -5,6 +5,7 @@ import { alsWochenblatt, berichteVon } from '@/lib/repos/berichtsheft';
import { berichtsheftPdf, type Blattangaben } from '@/lib/pdf/berichtsheft';
import { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { modulAn, modulGesperrt } from '@/lib/module/pruefen';
export const dynamic = 'force-dynamic';
@@ -20,6 +21,7 @@ const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
*/
export async function GET(anfrage: Request) {
const benutzer = await erfordereBenutzer();
if (!modulAn('berichtsheft')) return modulGesperrt();
const adresse = new URL(anfrage.url);
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (adresse.searchParams.get('azubi') ?? '');
@@ -19,6 +19,7 @@ import {
standVon,
} from '@/lib/repos/checklisten';
import { darfAbschliessen } from '@/lib/checklisten/fortschritt';
import { pruefeModul } from '@/lib/module/pruefen';
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. */
export async function laufZumLesen(laufId: string) {
const benutzer = await erfordereBenutzer();
pruefeModul('checklisten');
const lauf = laufNachId(laufId);
if (!lauf) return null;
@@ -59,6 +61,7 @@ function aktualisiere(laufId: string) {
export async function durchlaufStarten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
pruefeModul('checklisten');
if (benutzer.rolle !== 'azubi') {
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> {
pruefeModul('checklisten');
const laufId = String(formular.get('laufId') ?? '');
const schrittId = String(formular.get('schrittId') ?? '');
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> {
pruefeModul('checklisten');
const laufId = String(formular.get('laufId') ?? '');
const schrittId = String(formular.get('schrittId') ?? '');
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> {
pruefeModul('checklisten');
const laufId = String(formular.get('laufId') ?? '');
const { lauf } = await laufZumBearbeiten(laufId);
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> {
pruefeModul('checklisten');
const laufId = String(formular.get('laufId') ?? '');
const { lauf } = await laufZumBearbeiten(laufId);
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> {
pruefeModul('checklisten');
const laufId = String(formular.get('laufId') ?? '');
await laufZumBearbeiten(laufId);
laufWiederOeffnen(laufId);
@@ -155,6 +163,7 @@ export async function durchlaufWiederOeffnen(formular: FormData): Promise<void>
}
export async function durchlaufVerwerfen(formular: FormData): Promise<void> {
pruefeModul('checklisten');
const laufId = String(formular.get('laufId') ?? '');
const { lauf } = await laufZumBearbeiten(laufId);
// 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 { AbschlussFormular, BemerkungFormular, KopfdatenFormular } from '../../Formulare';
import { durchlaufVerwerfen, durchlaufWiederOeffnen, laufZumLesen, schrittUmschalten } from '../../aktionen';
import { erfordereModul } from '@/lib/module/pruefen';
import { erfordereBenutzer } from '@/lib/auth/rechte';
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 }> }) {
await erfordereBenutzer();
erfordereModul('checklisten');
const { laufId } = await params;
// 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 { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { laufZumLesen } from '../../../aktionen';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { modulAn, modulGesperrt } from '@/lib/module/pruefen';
export const dynamic = 'force-dynamic';
@@ -15,6 +17,8 @@ export const dynamic = 'force-dynamic';
* gibt, geht einen Unbefugten nichts an.
*/
export async function GET(_anfrage: Request, kontext: { params: Promise<{ laufId: string }> }) {
await erfordereBenutzer();
if (!modulAn('checklisten')) return modulGesperrt();
const { laufId } = await kontext.params;
const gelesen = await laufZumLesen(laufId);
@@ -6,12 +6,14 @@ import { inhalte } from '@/lib/inhalte';
import { laeufeVon, standVon } from '@/lib/repos/checklisten';
import { alsDatumText, alsZeitpunktText, heute } from '@/lib/zeit';
import { StartFormular } from './Formulare';
import { erfordereModul } from '@/lib/module/pruefen';
export const metadata: Metadata = { title: 'Checklisten' };
export const dynamic = 'force-dynamic';
export default async function ChecklistenSeite() {
const benutzer = await erfordereBenutzer();
erfordereModul('checklisten');
const alle = inhalte();
const istAzubi = benutzer.rolle === 'azubi';
@@ -8,6 +8,8 @@ import { istAbgeschlossen, zusammenfassung } from '@/lib/feedback/inhalt';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { Gespraechsformular } from '../Gespraechsformular';
import { bestaetigungZurueck, gespraechVerwerfen, gespraechZumLesen } from '../aktionen';
import { erfordereModul } from '@/lib/module/pruefen';
import { erfordereBenutzer } from '@/lib/auth/rechte';
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 }> }) {
await erfordereBenutzer();
erfordereModul('feedback');
const { gespraechId } = await params;
const gelesen = await gespraechZumLesen(gespraechId);
@@ -4,11 +4,15 @@ import { inhaltVon } from '@/lib/repos/feedback';
import { feedbackPdf } from '@/lib/pdf/feedback';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { gespraechZumLesen } from '../../aktionen';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { modulAn, modulGesperrt } from '@/lib/module/pruefen';
export const dynamic = 'force-dynamic';
/** Das Feedbackgespräch als PDF. Rechteprüfung über `gespraechZumLesen`. */
export async function GET(_anfrage: Request, kontext: { params: Promise<{ gespraechId: string }> }) {
await erfordereBenutzer();
if (!modulAn('feedback')) return modulGesperrt();
const { gespraechId } = await kontext.params;
const gelesen = await gespraechZumLesen(gespraechId);
@@ -16,6 +16,7 @@ import {
loeschen,
} from '@/lib/repos/feedback';
import { istAbgeschlossen, type Gespraechsinhalt } from '@/lib/feedback/inhalt';
import { pruefeModul } from '@/lib/module/pruefen';
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) {
const benutzer = await erfordereBenutzer();
pruefeModul('feedback');
const gespraech = gespraechNachId(id);
if (!gespraech) return null;
@@ -48,6 +50,7 @@ function aktualisiere(id?: string) {
export async function gespraechAnlegen(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
pruefeModul('feedback');
const azubiId = String(formular.get('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> {
pruefeModul('feedback');
const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id);
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> {
pruefeModul('feedback');
const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id);
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> {
pruefeModul('feedback');
const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id);
if (!gelesen) return;
@@ -164,6 +170,7 @@ export async function bestaetigungZurueck(formular: FormData): Promise<void> {
}
export async function gespraechVerwerfen(formular: FormData): Promise<void> {
pruefeModul('feedback');
const id = String(formular.get('gespraechId') ?? '');
const gelesen = await gespraechZumLesen(id);
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 { alsDatumText, heute, montagDerWoche } from '@/lib/zeit';
import { gespraechAnlegen } from './aktionen';
import { erfordereModul } from '@/lib/module/pruefen';
export const metadata: Metadata = { title: 'Feedbackgespräche' };
export const dynamic = 'force-dynamic';
export default async function FeedbackSeite() {
const benutzer = await erfordereBenutzer();
erfordereModul('feedback');
const vorlage = inhalte().feedback;
const beteiligte =
+21 -18
View File
@@ -8,6 +8,7 @@ import { gelesenAm } from '@/lib/repos/fortschritt';
import { alsZeitpunktText } from '@/lib/zeit';
import { gelesenUmschalten } from './aktionen';
import { Druckknopf } from '@/komponenten/Druckknopf';
import { modulStand } from '@/lib/repos/module';
export const dynamic = 'force-dynamic';
@@ -131,24 +132,26 @@ export default async function HandoutSeite({ params }: { params: Promise<{ id: s
</details>
) : null}
<section aria-labelledby="karten" className="karte stapel-eng">
<h2 id="karten" style={{ marginBottom: '0.3rem' }}>
Merkkarten zu diesem Handout
</h2>
<p className="leise" style={{ margin: 0 }}>
{handout.karten.length === 1 ? 'Eine Karte' : `${handout.karten.length} Karten`} – im Lernmodus tauchen sie
nach Fälligkeit auf.
</p>
<ol className="stapel-eng" style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
{handout.karten.map((karte, index) => (
<li key={index}>
<strong>{karte.frage}</strong>
<br />
<span className="leise">{karte.antwort}</span>
</li>
))}
</ol>
</section>
{modulStand().karten ? (
<section aria-labelledby="karten" className="karte stapel-eng">
<h2 id="karten" style={{ marginBottom: '0.3rem' }}>
Merkkarten zu diesem Handout
</h2>
<p className="leise" style={{ margin: 0 }}>
{handout.karten.length === 1 ? 'Eine Karte' : `${handout.karten.length} Karten`} – im Lernmodus tauchen sie
nach Fälligkeit auf.
</p>
<ol className="stapel-eng" style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
{handout.karten.map((karte, index) => (
<li key={index}>
<strong>{karte.frage}</strong>
<br />
<span className="leise">{karte.antwort}</span>
</li>
))}
</ol>
</section>
) : null}
{istAzubi ? (
<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 { bewertungen, fachNamen, type Bewertung } from '@/lib/lernen/sm2';
import { alsDatumText, heute } from '@/lib/zeit';
import { pruefeModul } from '@/lib/module/pruefen';
export type Bewertungsergebnis = {
fehler?: string;
@@ -25,6 +26,7 @@ export type Bewertungsergebnis = {
*/
export async function karteBewerten(karteId: string, bewertung: string): Promise<Bewertungsergebnis> {
const benutzer = await erfordereBenutzer();
pruefeModul('karten');
if (benutzer.rolle !== 'azubi') {
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 { heute } from '@/lib/zeit';
import { Lernmodus, type Lernkarte } from './Lernmodus';
import { erfordereModul } from '@/lib/module/pruefen';
export const metadata: Metadata = { title: 'Lernmodus' };
export const dynamic = 'force-dynamic';
@@ -15,6 +16,7 @@ const STAPELGROESSE = 20;
export default async function LernSeite({ searchParams }: { searchParams: Promise<{ block?: string }> }) {
const benutzer = await erfordereBenutzer();
erfordereModul('karten');
if (benutzer.rolle !== 'azubi') redirect('/karten');
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 { fach, fachNamen, heutigerStapel } from '@/lib/lernen/sm2';
import { alsDatumText, heute } from '@/lib/zeit';
import { erfordereModul } from '@/lib/module/pruefen';
export const metadata: Metadata = { title: 'Merkkarten' };
export const dynamic = 'force-dynamic';
@@ -13,6 +14,7 @@ const STAPELGROESSE = 20;
export default async function KartenSeite() {
const benutzer = await erfordereBenutzer();
erfordereModul('karten');
const alle = inhalte();
const heuteDatum = heute();
@@ -10,6 +10,7 @@ import { alsZeitpunktText } from '@/lib/zeit';
import { Fragebogen } from '../Fragebogen';
import { Korrektur, type Korrekturfrage } from '../Korrektur';
import { standZumLesen } from '../aktionen';
import { erfordereModul } from '@/lib/module/pruefen';
export const dynamic = 'force-dynamic';
@@ -27,6 +28,7 @@ export default async function KontrollSeite({
searchParams: Promise<{ azubi?: string }>;
}) {
const benutzer = await erfordereBenutzer();
erfordereModul('kontrollen');
const { kontrolleId } = await params;
const { azubi: azubiParameter } = await searchParams;
@@ -14,6 +14,7 @@ import {
standVon,
} from '@/lib/repos/kontrollen';
import { darfBearbeiten } from '@/lib/kontrollen/sicht';
import { pruefeModul } from '@/lib/module/pruefen';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
@@ -36,6 +37,7 @@ export async function antwortZwischenspeichern(
antwort: string,
): Promise<{ gespeichertUm: string } | { fehler: string }> {
const benutzer = await erfordereBenutzer();
pruefeModul('kontrollen');
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
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> {
const benutzer = await erfordereBenutzer();
pruefeModul('kontrollen');
const kontrolleId = String(formular.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> {
const ausbilder = await erfordereBenutzer();
pruefeModul('kontrollen');
const azubiId = String(formular.get('azubiId') ?? '');
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> {
const ausbilder = await erfordereBenutzer();
pruefeModul('kontrollen');
const azubiId = String(formular.get('azubiId') ?? '');
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> {
const ausbilder = await erfordereBenutzer();
pruefeModul('kontrollen');
const azubiId = String(formular.get('azubiId') ?? '');
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. */
export async function standZumLesen(azubiId: string, kontrolleId: string) {
const benutzer = await erfordereBenutzer();
pruefeModul('kontrollen');
const bezug = ausbildungVonAzubi(azubiId);
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 { alsZeitpunktText } from '@/lib/zeit';
import { kontrolleFreigeben, freigabeZurueck } from './aktionen';
import { erfordereModul } from '@/lib/module/pruefen';
export const metadata: Metadata = { title: 'Lernzielkontrollen' };
export const dynamic = 'force-dynamic';
@@ -21,6 +22,7 @@ const zustandsmarke: Record<Zustand, { text: string; ton: string; zeichen: strin
export default async function KontrollenSeite() {
const benutzer = await erfordereBenutzer();
erfordereModul('kontrollen');
const alle = inhalte();
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 { Netzzustand } from '@/komponenten/Netzzustand';
import { abmelden } from '../anmelden/aktionen';
import { modulStand } from '@/lib/repos/module';
import { nurOffene } from '@/lib/module/module';
export const dynamic = 'force-dynamic';
@@ -37,7 +39,8 @@ const fuerAusbilder: Navigationseintrag[] = [
export default async function AngemeldetLayout({ children }: { children: React.ReactNode }) {
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 (
<>
+13 -4
View File
@@ -10,6 +10,7 @@ import { wartetAufAbzeichnung } from '@/lib/repos/berichtsheft';
import { wartetAufKorrektur } from '@/lib/repos/kontrollen';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { WochenAnsicht } from './WochenAnsicht';
import { modulStand } from '@/lib/repos/module';
export const dynamic = 'force-dynamic';
@@ -151,10 +152,18 @@ function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
const meine = ausbildungenVonAusbilder(ausbilderId);
const alle = inhalte();
const azubiIds = meine.map(({ azubi }) => azubi.id);
const abnahmen = wartetAufAbnahme(azubiIds);
const berichte = wartetAufAbzeichnung(azubiIds);
const kontrollen = wartetAufKorrektur(azubiIds);
// Nur was zu eingeschalteten Modulen gehört – eine Abnahme, die man nicht
// öffnen kann, wäre ein toter Verweis.
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 art = [
eingeschaltet.aufgaben ? 'keine Abnahme' : null,
eingeschaltet.kontrollen ? 'keine Korrektur' : null,
eingeschaltet.berichtsheft ? 'kein Berichtsheft' : null,
].filter((teil) => teil !== null);
return (
<section className="karte stapel-eng" aria-labelledby="wartet">
@@ -164,7 +173,7 @@ function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
{gesamt === 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>
) : (
<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 { AusbildungFormular, AzubiAnlegenFormular, PasswortFormular } from './Formulare';
import { kontoAktivSetzen } from './aktionen';
import { Unternavigation } from './Unternavigation';
export const metadata: Metadata = { title: 'Verwaltung' };
export const dynamic = 'force-dynamic';
@@ -17,10 +18,13 @@ export default async function VerwaltungsSeite() {
<div>
<h1>Verwaltung</h1>
<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>
</div>
<Unternavigation aktiv="konten" />
<section className="karte stapel" aria-labelledby="ueberschrift-azubis">
<h2 id="ueberschrift-azubis" style={{ marginBottom: 0 }}>
Deine Auszubildenden
+2
View File
@@ -3,6 +3,7 @@ import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { ausbildungVonAzubi } from '@/lib/auth/rechte';
import { anhangLesen, anhangNachId } from '@/lib/repos/anhaenge';
import { istBild } from '@/lib/anhaenge/dateien';
import { modulAn, modulGesperrt } from '@/lib/module/pruefen';
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 }> }) {
const benutzer = await angemeldeterBenutzer();
if (!benutzer) return new Response('Nicht angemeldet.', { status: 401 });
if (!modulAn('aufgaben')) return modulGesperrt();
const { id } = await kontext.params;
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 { heute } from '@/lib/zeit';
import { tagDerBewertung } from '@/lib/offline/nachtrag';
import { modulAn } from '@/lib/module/pruefen';
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> {
const benutzer = await angemeldeterBenutzer();
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') {
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 { 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.
@@ -8,11 +20,12 @@ import type { MetadataRoute } from 'next';
* abgelaufen, leitet der Server ohnehin zur Anmeldung.
*/
export default function manifest(): MetadataRoute.Manifest {
const stand = modulStand();
return {
name: 'Lern-Bar – Betriebliche Ausbildung',
short_name: 'Lern-Bar',
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',
dir: 'ltr',
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-maskabel-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
shortcuts: [
{ name: 'Lernmodus', short_name: 'Lernen', url: '/karten/lernen' },
{ name: 'Berichtsheft', short_name: 'Bericht', url: '/berichtsheft' },
{ name: 'Handouts', short_name: 'Handouts', url: '/handouts' },
],
shortcuts: kurzbefehle.filter((kurz) => pfadOffen(kurz.url, stand)),
};
}
+9 -2
View File
@@ -7,6 +7,7 @@ import { berichteVon, fehlendeWochen } from '../repos/berichtsheft';
import { ergebnisVon, staendeVon as kontrollStaende } from '../repos/kontrollen';
import { alsDatumText, alsZeitpunktText } from '../zeit';
import { berechneAbdeckung, berechneLernfelder, type Nachweis } from './abdeckung';
import { modulStand } from '../repos/module';
/**
* 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) {
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 aufgaben = aufgabenStaende(azubiId);
const laeufe = laeufeVon(azubiId);
@@ -47,7 +51,7 @@ export function rahmenplanabdeckung(azubiId: string) {
}
// --- Praxisaufgaben ---
for (const aufgabe of alle.aufgaben) {
for (const aufgabe of eingeschaltet.aufgaben ? alle.aufgaben : []) {
const stand = aufgaben.get(aufgabe.id);
const nachweis: Nachweis = {
art: 'aufgabe',
@@ -61,7 +65,7 @@ export function rahmenplanabdeckung(azubiId: string) {
}
// --- Checklisten ---
for (const liste of alle.checklisten) {
for (const liste of eingeschaltet.checklisten ? alle.checklisten : []) {
const abgeschlossenAm = abgeschlosseneListen.get(liste.id);
const nachweis: Nachweis = {
art: 'checkliste',
@@ -75,6 +79,7 @@ export function rahmenplanabdeckung(azubiId: string) {
}
return {
module: eingeschaltet,
positionen: berechneAbdeckung(alle.positionen, jePosition),
lernfelder: berechneLernfelder(alle.lernfelder, jeLernfeld),
gruppennamen: alle.rahmenplan.gruppen,
@@ -84,6 +89,7 @@ export function rahmenplanabdeckung(azubiId: string) {
/** Der persönliche Stand des Azubis – was er selbst sehen will. */
export function eigenerStand(azubiId: string, beginn: string | null) {
const alle = inhalte();
const eingeschaltet = modulStand();
const gelesen = gelesenSet(azubiId);
const aufgaben = aufgabenStaende(azubiId);
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);
return {
module: eingeschaltet,
handouts: { erledigt: gelesen.size, gesamt: alle.handouts.length },
karten: { erledigt: karten.gelernt, gesamt: karten.gesamt, faellig: karten.faellig },
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)],
);
/** 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 Sitzung = typeof sitzung.$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 KontrollAntwort = typeof kontrollAntwort.$inferSelect;
export type Feedbackgespraech = typeof feedbackgespraech.$inferSelect;
export type ModulEinstellung = typeof modulEinstellung.$inferSelect;
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
export const alleTabellen = {
@@ -358,4 +367,5 @@ export const alleTabellen = {
kontrolle,
kontrollAntwort,
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',
});
// 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;
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 { anzahlAbgegeben } from '../repos/kontrollen';
import { heute } from '../zeit';
import { modulStand } from '../repos/module';
import type { ModulStand } from '../module/module';
import { aktuelleWoche } from './wochen';
import { wochenFortschritt, type Fortschritt } from './fortschritt';
import type { Wochenplanung } from '../db/schema';
@@ -34,6 +36,8 @@ export type Wochensicht = {
aufgaben: Aufgabe[];
checklisten: Checkliste[];
kontrolle: Kontrolle | null;
/** Welche Module an sind – die Ansicht blendet die übrigen aus. */
module: ModulStand;
vorherige: 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);
const checklisten = woche.checklisten.map((id) => alle.checklisteNach.get(id)).filter((c) => c !== undefined);
const kontrolle = woche.lernzielkontrolle ? (alle.kontrolleNach.get(woche.lernzielkontrolle) ?? null) : null;
// Ein abgeschaltetes Modul trägt nichts zur Woche bei – weder in der Ansicht
// noch im Fortschritt. So erreicht eine Woche auch ohne Merkkarten 100 %.
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.
const karteIds = handouts.flatMap(({ handout }) =>
alle.karten.filter((k) => k.handoutId === handout.id).map((k) => k.id),
);
const karteIds = eingeschaltet.karten
? handouts.flatMap(({ handout }) => alle.karten.filter((k) => k.handoutId === handout.id).map((k) => k.id))
: [];
const kartenStand = kartenUebersicht(azubiId, karteIds, heuteDatum);
const fortschritt = wochenFortschritt({
@@ -102,6 +114,7 @@ export function wochensicht(ausbildungId: string, azubiId: string, wochenNummer:
aufgaben,
checklisten,
kontrolle,
module: eingeschaltet,
vorherige: stelle > 0 ? (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();
}