Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3009c53b5c | ||
|
|
44e6c8bb79 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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**
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
@@ -254,7 +293,7 @@ mit Querverweisen.
|
||||
|
||||
---
|
||||
|
||||
### 11. Merkkarten lernen
|
||||
### 12. Merkkarten lernen
|
||||
|
||||

|
||||
|
||||
@@ -273,7 +312,7 @@ nachzuholen.
|
||||
|
||||
---
|
||||
|
||||
### 12. Eine Praxisaufgabe abgeben
|
||||
### 13. Eine Praxisaufgabe abgeben
|
||||
|
||||

|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
@@ -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 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lernbar",
|
||||
"version": "1.0.5",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lernbar",
|
||||
"version": "1.0.5",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lernbar",
|
||||
"version": "1.0.5",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration",
|
||||
"engines": {
|
||||
|
||||
@@ -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('/');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -155,6 +155,7 @@ export function WochenAnsicht({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{sicht.module.aufgaben ? (
|
||||
<section className="karte" aria-labelledby="woche-aufgaben">
|
||||
<h2 id="woche-aufgaben" style={{ fontSize: '1rem' }}>
|
||||
Praxisaufgaben
|
||||
@@ -173,7 +174,9 @@ export function WochenAnsicht({
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{sicht.module.checklisten ? (
|
||||
<section className="karte" aria-labelledby="woche-checklisten">
|
||||
<h2 id="woche-checklisten" style={{ fontSize: '1rem' }}>
|
||||
Checklisten
|
||||
@@ -188,7 +191,9 @@ export function WochenAnsicht({
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{sicht.module.kontrollen ? (
|
||||
<section className="karte" aria-labelledby="woche-kontrolle">
|
||||
<h2 id="woche-kontrolle" style={{ fontSize: '1rem' }}>
|
||||
Lernzielkontrolle
|
||||
@@ -207,6 +212,7 @@ export function WochenAnsicht({
|
||||
<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);
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -103,12 +103,15 @@ 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`}
|
||||
/>
|
||||
{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}`}
|
||||
@@ -119,6 +122,8 @@ function AzubiAuswertung({ azubiId, name }: { azubiId: string; name: string }) {
|
||||
: `${stand.aufgaben.offen} noch offen`
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{stand.module.berichtsheft ? (
|
||||
<Kachel
|
||||
titel="Berichtsheft abgezeichnet"
|
||||
wert={`${stand.berichtsheft.abgezeichnet} von ${stand.berichtsheft.gesamt}`}
|
||||
@@ -132,9 +137,10 @@ function AzubiAuswertung({ azubiId, name }: { azubiId: string; name: string }) {
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,6 +132,7 @@ export default async function HandoutSeite({ params }: { params: Promise<{ id: s
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{modulStand().karten ? (
|
||||
<section aria-labelledby="karten" className="karte stapel-eng">
|
||||
<h2 id="karten" style={{ marginBottom: '0.3rem' }}>
|
||||
Merkkarten zu diesem Handout
|
||||
@@ -149,6 +151,7 @@ export default async function HandoutSeite({ params }: { params: Promise<{ id: s
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{istAzubi ? (
|
||||
<form action={gelesenUmschalten} className="nicht-drucken">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 „Module“ 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
|
||||
|
||||
@@ -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,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
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user