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

|
||||
|
||||
Es gibt **keine Selbstregistrierung**. Das erste Ausbilderkonto legst du auf der
|
||||
Kommandozeile an (siehe README, Abschnitt *In fünf Minuten zum laufenden
|
||||
System*), alle weiteren Konten entstehen in der Verwaltung.
|
||||
|
||||
Nach dem Anmelden landest du auf deinem Überblick: was in dieser Woche ansteht,
|
||||
was offen ist, was auf dich wartet.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 2. Ein Azubikonto anlegen
|
||||
|
||||
**Verwaltung → Neues Azubikonto**
|
||||
|
||||

|
||||
|
||||
Du gibst Name, E-Mail-Adresse, ein erstes Passwort (mindestens 10 Zeichen) und
|
||||
den Ausbildungsbeginn an; Probezeit und geplantes Ende sind freiwillig.
|
||||
|
||||
Das Passwort gibst du dem Azubi **persönlich** weiter – er kann es danach selbst
|
||||
ändern. Einsehbar ist es danach nirgends mehr, auch für dich nicht: In der
|
||||
Datenbank liegt nur ein Argon2id-Hash. Hat jemand sein Passwort vergessen,
|
||||
setzt du in der Verwaltung ein neues; das alte lässt sich nicht anzeigen.
|
||||
|
||||
Der Ausbildungsbeginn ist wichtiger, als er aussieht. Aus ihm ergeben sich das
|
||||
Ausbildungsjahr und damit die Wochennummern im Berichtsheft.
|
||||
|
||||
> **Ein Azubi hat genau einen Ausbilder.** Das ist keine Einschränkung, die sich
|
||||
> später aufheben ließe, sondern die Grundlage der Rechteprüfung: Der Azubi
|
||||
> sieht ausschließlich seine eigenen Daten, der Ausbilder die seiner Azubis.
|
||||
|
||||
---
|
||||
|
||||
### 3. Den Wochenplan festlegen
|
||||
|
||||
**Wochenplan**
|
||||
|
||||

|
||||
|
||||
Die Wochen selbst stehen als Dateien unter `content/wochen/`. Hier legst du
|
||||
fest, **wann** welche Woche läuft, und kannst sie verschieben – Urlaub,
|
||||
Berufsschulblock, ein Projekt, das dazwischenkommt.
|
||||
|
||||
Verschiebst du eine Woche, wandern die folgenden mit. Was der Azubi schon
|
||||
erledigt hat, bleibt erledigt.
|
||||
|
||||
---
|
||||
|
||||
### 4. Eine Praxisaufgabe abnehmen
|
||||
|
||||
**Aufgaben → eine abgegebene Aufgabe öffnen**
|
||||
|
||||

|
||||
|
||||
Der Azubi gibt eine Aufgabe mit seiner Lösung und optional mit Anhängen ab –
|
||||
meist Fotos vom Handy. Du siehst die Abnahmekriterien aus der Aufgabendatei und
|
||||
hakst ab, was erfüllt ist.
|
||||
|
||||
Eine Aufgabe ist **abgenommen**, wenn du sie abgenommen hast. Nicht, wenn der
|
||||
Azubi sie abgegeben hat. Genau dieser Unterschied macht sie später in der
|
||||
Rahmenplanabdeckung zu einem belastbaren Nachweis.
|
||||
|
||||
Reicht etwas nicht, gibst du sie mit einer Bemerkung zurück. Der Azubi sieht,
|
||||
was fehlt, und kann nachbessern.
|
||||
|
||||
---
|
||||
|
||||
### 5. Das Berichtsheft abzeichnen
|
||||
|
||||
**Berichtshefte → ein Wochenblatt öffnen**
|
||||
|
||||

|
||||
|
||||
Der Ausbildungsnachweis ist der einzige Teil dieser Anwendung, den das Gesetz
|
||||
verlangt. Deshalb ist er der sorgfältigste.
|
||||
|
||||
Du liest das Blatt, siehst die Warnhinweise (leere Tage, Floskeln, mögliche
|
||||
Betriebs- oder Personennamen) und zeichnest ab. **Nach dem Abzeichnen ist das
|
||||
Blatt unveränderlich** – auch für dich. Willst du eine Änderung, gibst du es
|
||||
frei; das wird protokolliert.
|
||||
|
||||
Über *Als PDF* bekommst du das Blatt im IHK-Layout, mit Unterschriftenfeldern.
|
||||
Ein Beispiel liegt unter [`docs/beispiel-ausbildungsnachweis.pdf`](beispiel-ausbildungsnachweis.pdf).
|
||||
|
||||
---
|
||||
|
||||
### 6. Eine Lernzielkontrolle korrigieren
|
||||
|
||||
**Kontrollen**
|
||||
|
||||

|
||||
|
||||
Du gibst eine Kontrolle frei, der Azubi schreibt sie, du korrigierst. Punkte je
|
||||
Frage, daraus Prozent, daraus die Note nach dem **IHK-Notenschlüssel**
|
||||
(92/81/67/50/30 %).
|
||||
|
||||

|
||||
|
||||
> **Die Musterlösung bekommt der Azubi erst nach dem Absenden zu sehen.** Vorher
|
||||
> ist sie über keine Schnittstelle abrufbar – nicht im HTML, nicht in der
|
||||
> Nachladung, nirgends. Das wird serverseitig entschieden, nicht in der
|
||||
> Oberfläche, und mit einem eigenen Prüfskript nachgewiesen
|
||||
> (`scripts/pruefungen/musterloesung-dichtheit.mjs`).
|
||||
|
||||
---
|
||||
|
||||
### 7. Die Rahmenplanabdeckung lesen
|
||||
|
||||
**Auswertung**
|
||||
|
||||

|
||||
|
||||
Das ist die Ansicht, die gegenüber der IHK zählt. Sie beantwortet die Frage, die
|
||||
im Prüfungsausschuss gestellt wird: *Ist der Ausbildungsrahmenplan abgedeckt?*
|
||||
|
||||
Statt „erledigt / offen" gibt es **vier Lagen**:
|
||||
|
||||
| Lage | Bedeutung |
|
||||
|---|---|
|
||||
| **vermittelt** | eine abgenommene Praxisaufgabe oder ein abgeschlossener Checklisten-Durchlauf, **oder** mindestens zwei gelesene Handouts |
|
||||
| **angefangen** | etwas erledigt, aber noch nicht genug – ein gelesenes Handout allein genügt bewusst nicht |
|
||||
| **geplant** | Material vorhanden, aber unbearbeitet |
|
||||
| **ohne Inhalt** | zu dieser Position gibt es im ganzen Lehrgang nichts |
|
||||
|
||||
Die vierte Lage ist die wichtigste und wird rot ausgewiesen:
|
||||
|
||||

|
||||
|
||||
**Das ist keine Lücke beim Azubi, sondern eine im Lehrgang.** Dort hilft kein
|
||||
Lernen, sondern nur ein neues Handout oder eine neue Aufgabe unter `content/`.
|
||||
In den Beispielinhalten betrifft das fünf Positionen: A4, A10, C1, C2 und C5.
|
||||
|
||||
Jede Position führt ihre einzelnen Nachweise mit Kürzel und Zustand auf und
|
||||
nennt ihre amtliche Fundstelle in der Verordnung. Wenn jemand fragt, worauf sich
|
||||
die Bewertung stützt, steht die Antwort da.
|
||||
|
||||
---
|
||||
|
||||
### 8. Ein Feedbackgespräch führen
|
||||
|
||||
**Feedback → Neues Gespräch anlegen**
|
||||
|
||||

|
||||
|
||||
Monatlich, mit Leitfaden und Bewertungsbogen. Der Ablauf:
|
||||
|
||||
1. Du legst das Gespräch an und trägst **deine** Einschätzung ein: acht Kriterien
|
||||
auf einer Skala von 1 bis 5, dazu Stärken, Entwicklungsfelder und die
|
||||
vereinbarten Ziele.
|
||||
2. Der Azubi trägt **seine** Selbsteinschätzung ein. Er sieht deine Freitexte,
|
||||
kann sie aber nicht ändern – und du kannst seine Selbsteinschätzung nicht
|
||||
eintragen. Eine Selbsteinschätzung, die der Ausbilder eintragen könnte, wäre
|
||||
keine.
|
||||
3. Wo beide um **zwei Stufen oder mehr** auseinanderliegen, wird das
|
||||
hervorgehoben. Genau darüber lohnt das Gespräch – nicht über die Zahlen,
|
||||
sondern über die Stellen, an denen sie auseinandergehen.
|
||||
4. Beide bestätigen. Danach ist das Gespräch **unveränderlich**.
|
||||
|
||||

|
||||
|
||||
*Als PDF für die Akte* gibt beide Einschätzungen nebeneinander aus, mit
|
||||
Unterschriftenfeldern. Abweichungen sind auch dort markiert.
|
||||
|
||||
> Die Kriterien und der Leitfaden stehen in
|
||||
> `content/feedbackgespraech.yaml` und lassen sich an den eigenen Betrieb
|
||||
> anpassen. Ein bereits geführtes Gespräch behält seine Fassung, auch wenn du
|
||||
> die Vorlage später änderst.
|
||||
|
||||
---
|
||||
|
||||
## Für den Azubi
|
||||
|
||||
### 9. Die Woche
|
||||
|
||||

|
||||
|
||||
Die Startseite zeigt genau eine Woche: das Thema, was diese Woche zu tun ist,
|
||||
was schon erledigt ist, was drängt. Kein Dashboard mit zwölf Kacheln – die
|
||||
Frage lautet „Was mache ich jetzt?", nicht „Wie ist meine Gesamtlage?".
|
||||
|
||||
---
|
||||
|
||||
### 10. Handouts lesen und suchen
|
||||
|
||||

|
||||
|
||||
Die Handouts sind der Lesestoff: kurz, mit Beispielen, jeweils einem Thema
|
||||
zugeordnet. Die Suche geht über Titel und Volltext.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Unten markierst du ein Handout als gelesen. Das ist keine Spielerei: In der
|
||||
Auswertung zählen gelesene Handouts als **stützende** Nachweise für die
|
||||
Rahmenplanabdeckung.
|
||||
|
||||
Fachbegriffe, die dir unterwegs begegnen, stehen im **Glossar** – kurz erklärt,
|
||||
mit Querverweisen.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 11. Merkkarten lernen
|
||||
|
||||

|
||||
|
||||
Die Karten gehören zu den Handouts und kommen nach dem Prinzip der
|
||||
**Wiederholung nach Zeitabstand** wieder: Was sitzt, kommt später wieder; was
|
||||
nicht sitzt, heute noch einmal.
|
||||
|
||||
Im Lernmodus:
|
||||
|
||||
- **Antippen** oder **Leertaste** – Karte umdrehen
|
||||
- **1 / 2 / 3** – nochmal / unsicher / sitzt
|
||||
- **Wischen** nach rechts für „sitzt", nach links für „nochmal"
|
||||
|
||||
Zehn Minuten am Tag reichen. Wer das durchhält, hat vor der Prüfung nichts
|
||||
nachzuholen.
|
||||
|
||||
---
|
||||
|
||||
### 12. Eine Praxisaufgabe abgeben
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Du liest den Auftrag, siehst die Abnahmekriterien vorher – du weißt also, woran
|
||||
du gemessen wirst –, schreibst deine Lösung und hängst an, was dazugehört.
|
||||
Fotos vom Handy gehen direkt.
|
||||
|
||||
Dann gibst du ab. Der Ausbilder nimmt ab oder gibt zurück, mit Bemerkung.
|
||||
|
||||
---
|
||||
|
||||
### 13. Eine Checkliste abarbeiten
|
||||
|
||||

|
||||
|
||||
Checklisten sind für Abläufe, die immer gleich gehen: einen Arbeitsplatz
|
||||
übergeben, Windows installieren, einen Rechner zerlegen. Du startest einen
|
||||
Durchlauf und hakst Schritt für Schritt ab.
|
||||
|
||||

|
||||
|
||||
Ein Durchlauf **kopiert die Schritte bei seinem Start**. Ändert jemand die
|
||||
Checkliste, während du sie abarbeitest, bleibt dein Durchlauf so, wie du ihn
|
||||
begonnen hast. Am Ende gibt es ein PDF – ein Beispiel liegt unter
|
||||
[`docs/beispiel-checkliste.pdf`](beispiel-checkliste.pdf).
|
||||
|
||||
---
|
||||
|
||||
### 14. Das Berichtsheft führen
|
||||
|
||||

|
||||
|
||||
Pro Kalenderwoche ein Blatt, gegliedert nach Tagen, mit Stunden. Die Anwendung
|
||||
hilft an drei Stellen:
|
||||
|
||||
- **Vorschläge** aus dem, was du in der Woche tatsächlich gemacht hast:
|
||||
abgenommene Aufgaben, abgeschlossene Checklisten, gelesene Handouts.
|
||||
- **Prüfung vor dem Abgeben**: leere Tage, Floskeln („diverse Tätigkeiten",
|
||||
„wie immer"), verdächtige Namen von Firmen oder Personen. Der
|
||||
Ausbildungsnachweis geht an die IHK – da haben Kundennamen nichts verloren.
|
||||
- **Formulierungshilfe**: aus „Drucker repariert" wird „Fehlerbild aufgenommen,
|
||||
Fixiereinheit getauscht, Testdruck geprüft, Ticket dokumentiert".
|
||||
|
||||
Nach dem Abzeichnen ist das Blatt unveränderlich.
|
||||
|
||||
---
|
||||
|
||||
### 15. Eine Lernzielkontrolle schreiben
|
||||
|
||||
Der Ausbilder gibt die Kontrolle frei, du siehst sie unter *Kontrollen*. Du
|
||||
schreibst deine Antworten und sendest ab.
|
||||
|
||||
**Erst nach dem Absenden** siehst du die Musterlösung – vorher nicht, und auch
|
||||
nicht über Umwege. Das ist keine Gängelei, sondern der Sinn der Sache: Eine
|
||||
Kontrolle, deren Lösung vorher abrufbar wäre, misst nichts.
|
||||
|
||||
---
|
||||
|
||||
## Überall
|
||||
|
||||
### 16. Auf dem Handy
|
||||
|
||||
Die Anwendung ist für das Handy gebaut, nicht dafür angepasst. Der Azubi hat es
|
||||
in der Tasche, nicht den Laptop.
|
||||
|
||||
| Handout | Lernmodus, Frage | Lernmodus, Antwort |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
|
||||
**Auf den Startbildschirm legen:** Die Lern-Bar ist eine installierbare
|
||||
Anwendung.
|
||||
|
||||
- *Android (Chrome):* Menü → **App installieren**
|
||||
- *iPhone (Safari):* Teilen → **Zum Home-Bildschirm**
|
||||
|
||||
Danach startet sie ohne Adresszeile und mit eigenem Symbol.
|
||||
|
||||
---
|
||||
|
||||
### 17. Ohne Netz
|
||||
|
||||
Im Serverraum, im Keller, im Zug gibt es kein Netz. Deshalb:
|
||||
|
||||
- **Merkkarten** und **Handouts, die du schon einmal geöffnet hattest**,
|
||||
funktionieren weiter.
|
||||
- Deine **Bewertungen werden gemerkt** und gehen ab, sobald du wieder verbunden
|
||||
bist. Eine schmale Leiste sagt dir, wie viele warten – und meldet sich, wenn
|
||||
sie angekommen sind.
|
||||
- Der Tag zählt, an dem du **gelernt** hast, nicht der Tag des Absendens.
|
||||
|
||||
Was **nicht** offline liegt: Lernzielkontrollen, Berichtsheft, Bewertungen,
|
||||
Feedbackgespräche, Anhänge und jedes PDF. Das ist Absicht. Diese Dinge stehen
|
||||
hinter einer eigenen Rechteprüfung, und was im Browserspeicher liegt, ist an
|
||||
dieser Prüfung vorbei.
|
||||
|
||||
Auf einem geteilten Gerät wird beim Abmelden alles Zwischengespeicherte
|
||||
weggeräumt.
|
||||
|
||||
---
|
||||
|
||||
### 18. Hell und dunkel
|
||||
|
||||

|
||||
|
||||
Oben rechts in der Kopfzeile schaltest du um. Die Einstellung bleibt auf dem
|
||||
Gerät. Beim Drucken wird immer hell gedruckt, auch wenn du dunkel eingestellt
|
||||
hast – sonst käme schwarzes Papier aus dem Drucker.
|
||||
|
||||
---
|
||||
|
||||
### 19. Drucken
|
||||
|
||||
Für die Dokumente, die eine Unterschrift tragen, gibt es **PDFs**:
|
||||
Wochenblatt, Checklisten-Durchlauf, Feedbackgespräch.
|
||||
|
||||
Für alles andere gibt es einen **Druckknopf**: Handout, Glossar, Auswertung. Die
|
||||
Navigation, Knöpfe und Hinweise werden nicht mitgedruckt, Karten werden nicht
|
||||
mitten durchgeschnitten, aufklappbare Abschnitte drucken aufgeklappt.
|
||||
|
||||
---
|
||||
|
||||
### 20. Wenn etwas nicht geht
|
||||
|
||||
| Beobachtung | Woran es liegt |
|
||||
|---|---|
|
||||
| „Diese Seite gibt es nicht" bei fremden Daten | Kein Fehler, sondern die Rechteprüfung. Ob es zu einer Kennung überhaupt Daten gibt, geht Unbefugte nichts an – deshalb 404 und nicht „kein Zugriff". |
|
||||
| Die Anwendung startet nicht, nennt aber Datei und Zeile | Ein Inhaltsfehler unter `content/`. Die Meldung sagt genau, wo. `npm run inhalte:pruefen` prüft ohne Start. |
|
||||
| Ein Anhang lässt sich nicht hochladen | Über 25 MB. Bei einem Reverse Proxy zusätzlich `client_max_body_size 30m` setzen (siehe README). |
|
||||
| Ein abgezeichnetes Blatt lässt sich nicht ändern | So gewollt. Der Ausbilder kann es freigeben; das wird protokolliert. |
|
||||
| Offline erscheint „Kein Netz" | Diese Seite lag nicht auf dem Gerät. Merkkarten und schon geöffnete Handouts gehen weiter. |
|
||||
|
||||
Bleibt etwas offen: `docker compose logs -f` zeigt, was der Server sagt, und
|
||||
`/api/gesundheit` antwortet nur dann mit 200, wenn die Datenbank ansprechbar
|
||||
ist.
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 503 KiB After Width: | Height: | Size: 506 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 103 KiB |
@@ -7,6 +7,12 @@ const konfiguration: NextConfig = {
|
||||
serverExternalPackages: ['better-sqlite3', '@node-rs/argon2', 'pdfkit'],
|
||||
poweredByHeader: false,
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
// Next.js deckelt Serveraktionen sonst bei 1 MB – damit wäre kein einziges
|
||||
// Handyfoto durchgekommen. Etwas über der eigenen Grenze von 25 MB, damit
|
||||
// eine zu große Datei die *eigene* Meldung bekommt und nicht die des Gerüsts.
|
||||
serverActions: { bodySizeLimit: '30mb' },
|
||||
},
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
async headers() {
|
||||
return [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lernbar",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration",
|
||||
"engines": {
|
||||
@@ -19,7 +19,9 @@
|
||||
"konto:anlegen": "tsx scripts/konto-anlegen.ts",
|
||||
"backup": "bash scripts/im-container/sicherung.sh",
|
||||
"restore": "bash scripts/im-container/wiederherstellen.sh",
|
||||
"screenshots": "tsx scripts/screenshots.ts"
|
||||
"screenshots": "tsx scripts/screenshots.ts",
|
||||
"symbole": "tsx scripts/symbole.ts",
|
||||
"abnahme": "node scripts/pruefungen/abnahme.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Der Dienstarbeiter der Lern-Bar.
|
||||
*
|
||||
* Er hat genau eine Aufgabe: Der Azubi soll im Serverraum, im Keller oder im
|
||||
* Zug weiterlernen können, wo kein Netz ist. Alles andere wäre Ballast.
|
||||
*
|
||||
* ─── Was offline liegen darf ──────────────────────────────────────────────
|
||||
*
|
||||
* Nur das Lernmaterial: Handouts, Merkkarten, Glossar und die eigene Woche.
|
||||
*
|
||||
* **Nicht** zwischengespeichert werden Lernzielkontrollen, Berichtsheft,
|
||||
* Bewertungen, Feedbackgespräche, Anhänge und jedes PDF. Der Grund ist keine
|
||||
* Sparsamkeit, sondern eine Zusage aus dem Pflichtenheft: Musterlösungen und
|
||||
* Bewertungsdaten stehen hinter einer eigenen Prüfung. Was im Browsercache
|
||||
* liegt, ist an dieser Prüfung vorbei – also liegt es dort nicht.
|
||||
*
|
||||
* ─── Wie zwischengespeichert wird ─────────────────────────────────────────
|
||||
*
|
||||
* Seiten: erst das Netz, dann der Speicher. So sieht man nach einer
|
||||
* Aktualisierung sofort die neue Fassung und nur bei fehlendem Netz die alte.
|
||||
*
|
||||
* Dateien unter /_next/static/: erst der Speicher. Die Namen tragen eine
|
||||
* Prüfsumme, ihr Inhalt ändert sich nie.
|
||||
*
|
||||
* Alles andere – Serveraktionen, Anmeldung, POST – geht immer ans Netz.
|
||||
*/
|
||||
|
||||
const FASSUNG = 'v1';
|
||||
const SCHALE = `lernbar-schale-${FASSUNG}`;
|
||||
const SEITEN = `lernbar-seiten-${FASSUNG}`;
|
||||
const DATEIEN = `lernbar-dateien-${FASSUNG}`;
|
||||
|
||||
/** Was schon beim Einrichten vorliegen muss. */
|
||||
const GRUNDVORRAT = ['/offline', '/symbol-192.png', '/lernbar.svg', '/manifest.webmanifest'];
|
||||
|
||||
/** Höchstens so viele Seiten liegen im Speicher – ein Handy ist kein Archiv. */
|
||||
const HOECHSTZAHL_SEITEN = 60;
|
||||
|
||||
/** Nur diese Pfade dürfen offline vorliegen. Alles andere niemals. */
|
||||
function darfOfflineLiegen(pfad) {
|
||||
if (pfad === '/' || pfad === '/offline' || pfad === '/glossar') return true;
|
||||
if (pfad === '/handouts' || pfad.startsWith('/handouts/')) return true;
|
||||
if (pfad === '/karten' || pfad.startsWith('/karten/')) return true;
|
||||
if (/^\/woche\/\d+$/.test(pfad)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
self.addEventListener('install', (ereignis) => {
|
||||
ereignis.waitUntil(
|
||||
caches
|
||||
.open(SCHALE)
|
||||
// Einzeln, damit ein fehlendes Stück nicht die ganze Einrichtung kippt.
|
||||
.then((speicher) => Promise.all(GRUNDVORRAT.map((pfad) => speicher.add(pfad).catch(() => undefined))))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (ereignis) => {
|
||||
ereignis.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((namen) =>
|
||||
Promise.all(
|
||||
namen
|
||||
.filter((name) => name.startsWith('lernbar-') && ![SCHALE, SEITEN, DATEIEN].includes(name))
|
||||
.map((name) => caches.delete(name)),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Beim Abmelden wird alles Persönliche weggeräumt.
|
||||
*
|
||||
* Ein geteiltes Werkstattgerät soll nach dem Abmelden nicht noch die Woche des
|
||||
* vorigen Azubis im Speicher haben.
|
||||
*/
|
||||
self.addEventListener('message', (ereignis) => {
|
||||
if (ereignis.data && ereignis.data.typ === 'aufraeumen') {
|
||||
ereignis.waitUntil(Promise.all([caches.delete(SEITEN), caches.delete(DATEIEN)]));
|
||||
}
|
||||
});
|
||||
|
||||
async function speicherKuerzen(name, hoechstzahl) {
|
||||
const speicher = await caches.open(name);
|
||||
const schluessel = await speicher.keys();
|
||||
// Die ältesten zuerst – `keys()` liefert sie in der Reihenfolge des Ablegens.
|
||||
for (const alt of schluessel.slice(0, Math.max(0, schluessel.length - hoechstzahl))) {
|
||||
await speicher.delete(alt);
|
||||
}
|
||||
}
|
||||
|
||||
/** Seiten: erst das Netz, bei Ausfall der Speicher, sonst die Offlineseite. */
|
||||
async function seiteHolen(anfrage) {
|
||||
const pfad = new URL(anfrage.url).pathname;
|
||||
|
||||
try {
|
||||
const antwort = await fetch(anfrage);
|
||||
|
||||
if (antwort.ok && darfOfflineLiegen(pfad)) {
|
||||
const speicher = await caches.open(SEITEN);
|
||||
await speicher.put(anfrage, antwort.clone());
|
||||
await speicherKuerzen(SEITEN, HOECHSTZAHL_SEITEN);
|
||||
}
|
||||
|
||||
return antwort;
|
||||
} catch (fehler) {
|
||||
const gespeichert = await caches.match(anfrage, { ignoreSearch: false });
|
||||
if (gespeichert) return gespeichert;
|
||||
|
||||
const offline = await caches.match('/offline');
|
||||
if (offline) return offline;
|
||||
|
||||
return new Response('Kein Netz und nichts im Speicher.', {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Unveränderliche Dateien: erst der Speicher. */
|
||||
async function dateiHolen(anfrage) {
|
||||
const gespeichert = await caches.match(anfrage);
|
||||
if (gespeichert) return gespeichert;
|
||||
|
||||
const antwort = await fetch(anfrage);
|
||||
if (antwort.ok) {
|
||||
const speicher = await caches.open(DATEIEN);
|
||||
await speicher.put(anfrage, antwort.clone());
|
||||
}
|
||||
return antwort;
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (ereignis) => {
|
||||
const anfrage = ereignis.request;
|
||||
|
||||
// Nur eigene Adressen, nur Lesezugriffe.
|
||||
if (anfrage.method !== 'GET') return;
|
||||
|
||||
const adresse = new URL(anfrage.url);
|
||||
if (adresse.origin !== self.location.origin) return;
|
||||
|
||||
// Serveraktionen und Datenschnittstellen gehen immer ans Netz – eine
|
||||
// zwischengespeicherte Antwort wäre hier schlicht falsch.
|
||||
if (adresse.pathname.startsWith('/api/') || anfrage.headers.has('Next-Action')) return;
|
||||
|
||||
// Auch die Teilnachladungen von Next (RSC) nicht: Sie gehören zu einer
|
||||
// Sitzung und sollen nicht als Seite verwechselt werden.
|
||||
if (adresse.searchParams.has('_rsc')) return;
|
||||
|
||||
if (anfrage.mode === 'navigate') {
|
||||
ereignis.respondWith(seiteHolen(anfrage));
|
||||
return;
|
||||
}
|
||||
|
||||
if (adresse.pathname.startsWith('/_next/static/') || adresse.pathname.endsWith('.svg') || adresse.pathname.endsWith('.png')) {
|
||||
ereignis.respondWith(dateiHolen(anfrage));
|
||||
}
|
||||
});
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
@@ -46,7 +46,12 @@ Inhalt: lernbar.sqlite (Datenbank), anhaenge/ (hochgeladene Dateien)
|
||||
Zurückspielen: wiederherstellen.sh <diese-datei.tar.gz>
|
||||
ENDE
|
||||
|
||||
ARCHIV="$ZIEL/lernbar-$STEMPEL.tar.gz"
|
||||
tar -czf "$ARCHIV" -C "$ARBEIT" lernbar.sqlite anhaenge sicherung.txt
|
||||
# Bewusst erst ins Zielverzeichnis wechseln und dann einen blossen Dateinamen
|
||||
# uebergeben: tar deutet einen Doppelpunkt im Dateinamen als Rechnernamen
|
||||
# ("C:\..." wird zu Rechner "C"). Das faellt nur auf Windows auf, aber ein
|
||||
# Pfad mit Doppelpunkt kann ueberall vorkommen.
|
||||
NAME="lernbar-$STEMPEL.tar.gz"
|
||||
ARCHIV="$ZIEL/$NAME"
|
||||
( cd "$ZIEL" && tar -czf "$NAME" -C "$ARBEIT" lernbar.sqlite anhaenge sicherung.txt )
|
||||
|
||||
echo "$ARCHIV"
|
||||
|
||||
@@ -36,7 +36,10 @@ fi
|
||||
ARBEIT="$(mktemp -d)"
|
||||
trap 'rm -rf "$ARBEIT"' EXIT
|
||||
|
||||
tar -xzf "$ARCHIV" -C "$ARBEIT"
|
||||
# Siehe sicherung.sh: tar darf den Doppelpunkt im Pfad nicht zu sehen bekommen.
|
||||
VERZEICHNIS="$(cd "$(dirname "$ARCHIV")" && pwd)"
|
||||
DATEI="$(basename "$ARCHIV")"
|
||||
( cd "$VERZEICHNIS" && tar -xzf "$DATEI" -C "$ARBEIT" )
|
||||
|
||||
if [ ! -f "$ARBEIT/lernbar.sqlite" ]; then
|
||||
echo "Das Archiv enthält keine lernbar.sqlite – falsche Datei?" >&2
|
||||
|
||||
@@ -30,6 +30,7 @@ function main() {
|
||||
console.log(` Glossareinträge ${alle.glossar.length}`);
|
||||
console.log(` Berufsbildpositionen ${alle.positionen.length}`);
|
||||
console.log(` Lernfelder ${alle.lernfelder.length}`);
|
||||
console.log(` Feedbackkriterien ${alle.feedback.kriterien.length}`);
|
||||
console.log('');
|
||||
|
||||
const ohneAbdeckung = alle.positionen.filter(
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Der Abnahmelauf.
|
||||
*
|
||||
* Anders als die Prüfungen der einzelnen Bausteine läuft dieser hier gegen
|
||||
* eine **frische, leere Datenbank** in einem Wegwerfverzeichnis. Er beweist
|
||||
* damit nicht nur, dass die Anwendung funktioniert, sondern dass sie sich aus
|
||||
* dem Nichts aufsetzen lässt: Migrationen laufen an, das erste Ausbilderkonto
|
||||
* entsteht auf der Kommandozeile, alles Weitere über die Oberfläche.
|
||||
*
|
||||
* Am Ende wird gesichert, absichtlich etwas kaputtgemacht und zurückgespielt.
|
||||
* Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung.
|
||||
*
|
||||
* node scripts/pruefungen/abnahme.mjs
|
||||
*
|
||||
* Voraussetzung: `npm run build` ist gelaufen.
|
||||
*/
|
||||
|
||||
const PORT = Number(process.env.LERNBAR_ABNAHME_PORT ?? 3399);
|
||||
const BASIS = `http://localhost:${PORT}`;
|
||||
|
||||
const AUSBILDER = { name: 'Petra Kessler', email: 'petra.kessler@betrieb.test', pw: 'abnahme-ausbilder' };
|
||||
const AZUBI = { name: 'Tim Vogt', email: 'tim.vogt@betrieb.test', pw: 'abnahme-azubi-2026' };
|
||||
|
||||
let fehler = 0;
|
||||
const protokoll = [];
|
||||
|
||||
function pruefe(bedingung, text, zusatz = '') {
|
||||
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
|
||||
protokoll.push({ erfuellt: Boolean(bedingung), text, zusatz });
|
||||
if (!bedingung) fehler += 1;
|
||||
}
|
||||
|
||||
function abschnitt(titel) {
|
||||
console.log(`\n${titel}`);
|
||||
protokoll.push({ abschnitt: titel });
|
||||
}
|
||||
|
||||
// --- Wegwerfumgebung -------------------------------------------------------
|
||||
|
||||
const daten = fs.mkdtempSync(path.join(os.tmpdir(), 'lernbar-abnahme-'));
|
||||
const umgebung = { ...process.env, LERNBAR_DATEN: daten, PORT: String(PORT), TZ: 'Europe/Berlin' };
|
||||
|
||||
console.log(`Wegwerfverzeichnis: ${daten}`);
|
||||
|
||||
let server = null;
|
||||
let browser = null;
|
||||
|
||||
/**
|
||||
* Den Server beenden – wirklich.
|
||||
*
|
||||
* Unter Windows beendet `kill()` auf einem über die Eingabeaufforderung
|
||||
* gestarteten Prozess nur diese, nicht den Node-Prozess darunter. Deshalb wird
|
||||
* hier ohne Eingabeaufforderung gestartet und zur Sicherheit der ganze Baum
|
||||
* abgeräumt. Ein überlebender Server aus einem früheren Lauf ist kein
|
||||
* Schönheitsfehler: Er belegt den Port, der nächste Lauf bekommt ihn nicht,
|
||||
* und dann prüft man stundenlang gegen den alten Datenbestand.
|
||||
*/
|
||||
function serverBeenden() {
|
||||
if (!server) return;
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawnSync('taskkill', ['/PID', String(server.pid), '/T', '/F'], { stdio: 'ignore' });
|
||||
} else {
|
||||
server.kill('SIGTERM');
|
||||
}
|
||||
} catch {
|
||||
/* egal */
|
||||
}
|
||||
server = null;
|
||||
}
|
||||
|
||||
function aufraeumen() {
|
||||
serverBeenden();
|
||||
try {
|
||||
fs.rmSync(daten, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Unter Windows hält der Serverprozess die Datei manchmal noch kurz.
|
||||
}
|
||||
}
|
||||
process.on('exit', aufraeumen);
|
||||
|
||||
/** Antwortet dort schon jemand? Dann ist es nicht unser Server. */
|
||||
async function portBelegt() {
|
||||
try {
|
||||
await fetch(`${BASIS}/api/gesundheit`, { signal: AbortSignal.timeout(2000) });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Wartet, bis der Server antwortet. */
|
||||
async function serverBereit(sekunden = 180) {
|
||||
for (let versuch = 0; versuch < sekunden * 2; versuch += 1) {
|
||||
if (server?.exitCode !== null && server?.exitCode !== undefined) return false;
|
||||
try {
|
||||
const antwort = await fetch(`${BASIS}/api/gesundheit`);
|
||||
if (antwort.ok) return true;
|
||||
} catch {
|
||||
/* noch nicht da */
|
||||
}
|
||||
await new Promise((weiter) => setTimeout(weiter, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function laufen(befehl, argumente, zusatz = {}) {
|
||||
return spawnSync(befehl, argumente, {
|
||||
env: umgebung,
|
||||
encoding: 'utf8',
|
||||
shell: process.platform === 'win32',
|
||||
...zusatz,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// --- A1 Aufsetzen aus dem Nichts -----------------------------------------
|
||||
|
||||
abschnitt('A1 · Aus dem Nichts aufsetzbar');
|
||||
|
||||
if (await portBelegt()) {
|
||||
throw new Error(
|
||||
`Auf ${BASIS} antwortet bereits etwas. Dieser Lauf braucht den Port für sich – ` +
|
||||
'sonst prüft er gegen einen fremden Datenbestand. Bitte beenden oder ' +
|
||||
'LERNBAR_ABNAHME_PORT setzen.',
|
||||
);
|
||||
}
|
||||
|
||||
const konto = laufen('npx', [
|
||||
'tsx', 'scripts/konto-anlegen.ts',
|
||||
'--name', AUSBILDER.name,
|
||||
'--email', AUSBILDER.email,
|
||||
'--rolle', 'ausbilder',
|
||||
'--passwort', AUSBILDER.pw,
|
||||
]);
|
||||
pruefe(konto.status === 0, 'Das erste Ausbilderkonto entsteht auf der Kommandozeile', (konto.stderr || '').trim().split('\n')[0] || 'ohne Fehler');
|
||||
pruefe(
|
||||
fs.existsSync(path.join(daten, 'lernbar.sqlite')),
|
||||
'Die Datenbank wurde angelegt und die Migrationen liefen an',
|
||||
);
|
||||
|
||||
// Ohne Eingabeaufforderung starten: nur dann lässt sich der Prozess später
|
||||
// auch wirklich beenden.
|
||||
server = spawn(process.execPath, [path.join('node_modules', 'next', 'dist', 'bin', 'next'), 'start', '-p', String(PORT)], {
|
||||
env: umgebung,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let serverAusgabe = '';
|
||||
server.stdout.on('data', (stueck) => (serverAusgabe += stueck));
|
||||
server.stderr.on('data', (stueck) => (serverAusgabe += stueck));
|
||||
|
||||
const laeuft = await serverBereit();
|
||||
pruefe(laeuft, 'Die Anwendung startet und meldet sich gesund', laeuft ? '' : serverAusgabe.slice(-400));
|
||||
if (!laeuft) throw new Error('Server nicht erreichbar.');
|
||||
|
||||
// Die Zusage lautet: Bei einem Inhaltsfehler startet die Anwendung nicht und
|
||||
// nennt Datei und Zeile. Das prüft man, indem man einen Fehler einbaut –
|
||||
// nicht, indem man in der Startausgabe nach einem Wort sucht.
|
||||
const heil = laufen('npx', ['tsx', 'scripts/inhalte-pruefen.ts']);
|
||||
pruefe(
|
||||
heil.status === 0,
|
||||
'Heile Inhalte werden ohne Beanstandung gelesen',
|
||||
((heil.stdout || '').match(/Inhalte in Ordnung/) ?? ['—'])[0],
|
||||
);
|
||||
|
||||
const kaputteInhalte = path.join(daten, 'kaputte-inhalte');
|
||||
fs.cpSync(path.join(process.cwd(), 'content'), kaputteInhalte, { recursive: true });
|
||||
|
||||
// Ein Verweis auf eine Rahmenplanposition, die es nicht gibt. Das ist der
|
||||
// Fehler, der ohne Prüfung erst Monate später auffiele – in der Auswertung.
|
||||
const opfer = path.join(kaputteInhalte, 'handouts', 'N3-subnetting-rechnen.md');
|
||||
const zeilen = fs.readFileSync(opfer, 'utf8').split(/\r?\n/);
|
||||
const stelle = zeilen.findIndex((zeile) => zeile.startsWith('rahmenplan:'));
|
||||
// Wohlgeformt, aber ohne Entsprechung – so greift die Querprüfung zwischen
|
||||
// den Dateien und nicht bloß die Formatprüfung.
|
||||
zeilen[stelle] = 'rahmenplan: [B2, C9]';
|
||||
fs.writeFileSync(opfer, zeilen.join('\n'), 'utf8');
|
||||
const erwarteteZeile = stelle + 1;
|
||||
|
||||
const kaputt = spawnSync('npx', ['tsx', 'scripts/inhalte-pruefen.ts'], {
|
||||
env: { ...umgebung, LERNBAR_INHALTE: kaputteInhalte },
|
||||
encoding: 'utf8',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
const meldung = `${kaputt.stdout ?? ''}${kaputt.stderr ?? ''}`;
|
||||
pruefe(kaputt.status !== 0, 'Ein Inhaltsfehler lässt die Prüfung fehlschlagen', `Ende ${kaputt.status}`);
|
||||
pruefe(
|
||||
/N3-subnetting-rechnen\.md/.test(meldung),
|
||||
'Die Meldung nennt die Datei',
|
||||
(meldung.match(/.*N3-subnetting-rechnen\.md.*/) ?? ['—'])[0].trim().slice(0, 100),
|
||||
);
|
||||
pruefe(
|
||||
meldung.includes(`N3-subnetting-rechnen.md:${erwarteteZeile}`),
|
||||
'Die Meldung nennt die richtige Zeile',
|
||||
`erwartet Zeile ${erwarteteZeile}`,
|
||||
);
|
||||
pruefe(meldung.includes('C9'), 'Die Meldung nennt den fehlerhaften Wert');
|
||||
pruefe(meldung.includes('rahmenplan.yaml'), 'Die Meldung sagt, wo die gültigen Werte stehen');
|
||||
|
||||
browser = await chromium.launch();
|
||||
|
||||
// --- A2 Zwei Rollen, keine Selbstregistrierung ---------------------------
|
||||
|
||||
abschnitt('A2 · Zwei Rollen, keine Selbstregistrierung');
|
||||
|
||||
const gast = await browser.newContext({ locale: 'de-DE' });
|
||||
const gastSeite = await gast.newPage();
|
||||
gastSeite.setDefaultTimeout(60000);
|
||||
await gastSeite.goto(`${BASIS}/anmelden`);
|
||||
const anmeldetext = await gastSeite.locator('body').innerText();
|
||||
pruefe(/keine Selbstregistrierung/i.test(anmeldetext), 'Die Anmeldeseite bietet keine Registrierung');
|
||||
|
||||
const ungeschuetzt = await gast.request.get(`${BASIS}/berichtsheft`, { maxRedirects: 0 });
|
||||
pruefe(
|
||||
ungeschuetzt.status() >= 300 && ungeschuetzt.status() < 400,
|
||||
'Ohne Anmeldung führt jeder Pfad zur Anmeldung',
|
||||
`HTTP ${ungeschuetzt.status()}`,
|
||||
);
|
||||
await gast.close();
|
||||
|
||||
const ausbilder = await browser.newContext({ locale: 'de-DE' });
|
||||
const a = await ausbilder.newPage();
|
||||
a.setDefaultTimeout(60000);
|
||||
await a.goto(`${BASIS}/anmelden`);
|
||||
await a.fill('#email', AUSBILDER.email);
|
||||
await a.fill('#passwort', AUSBILDER.pw);
|
||||
await a.click('button[type=submit]');
|
||||
await a.waitForURL(`${BASIS}/`);
|
||||
pruefe(true, 'Der Ausbilder meldet sich an');
|
||||
|
||||
// --- A3 Der Ausbilder legt ein Azubikonto an -----------------------------
|
||||
|
||||
abschnitt('A3 · Der Ausbilder legt Konten an');
|
||||
|
||||
await a.goto(`${BASIS}/verwaltung`);
|
||||
await a.waitForSelector('#neu-name');
|
||||
await a.fill('#neu-name', AZUBI.name);
|
||||
await a.fill('#neu-email', AZUBI.email);
|
||||
await a.fill('#neu-passwort', AZUBI.pw);
|
||||
await a.fill('#neu-beginn', '2026-08-03');
|
||||
|
||||
await a.click('form:has(#neu-name) button[type=submit]');
|
||||
await a.waitForSelector(`text=${AZUBI.name}`, { timeout: 20000 });
|
||||
|
||||
const verwaltungstext = await a.locator('body').innerText();
|
||||
pruefe(verwaltungstext.includes(AZUBI.name), 'Der Azubi steht in der Verwaltung');
|
||||
|
||||
// Das Passwort darf nirgends wieder auftauchen – weder in der Ansicht noch
|
||||
// in der Datenbank.
|
||||
await a.reload();
|
||||
const nachNeuladen = await a.content();
|
||||
pruefe(!nachNeuladen.includes(AZUBI.pw), 'Das Passwort ist in der Oberfläche nirgends mehr zu sehen');
|
||||
|
||||
const { default: Sqlite } = await import('better-sqlite3');
|
||||
// Bewusst **nicht** readonly: Eine Lesesitzung auf einer WAL-Datenbank sieht
|
||||
// ohne Schreibrecht auf die Begleitdateien unter Umständen nur den letzten
|
||||
// Prüfpunkt – und damit ein frisch angelegtes Konto noch nicht.
|
||||
const pruefDb = new Sqlite(path.join(daten, 'lernbar.sqlite'));
|
||||
const alleKonten = pruefDb.prepare('select email, rolle from benutzer').all();
|
||||
const gespeichert = pruefDb.prepare('select passwort_hash from benutzer where email = ?').get(AZUBI.email);
|
||||
pruefDb.close();
|
||||
if (!gespeichert) {
|
||||
console.log(` (in der Datenbank stehen: ${alleKonten.map((k) => `${k.email} [${k.rolle}]`).join(', ') || 'keine'})`);
|
||||
}
|
||||
pruefe(
|
||||
Boolean(gespeichert) && !String(gespeichert.passwort_hash).includes(AZUBI.pw),
|
||||
'In der Datenbank steht kein Klartextpasswort',
|
||||
);
|
||||
pruefe(
|
||||
String(gespeichert?.passwort_hash ?? '').startsWith('$argon2id$'),
|
||||
'Das Passwort liegt als Argon2id-Hash',
|
||||
String(gespeichert?.passwort_hash ?? '').slice(0, 30),
|
||||
);
|
||||
|
||||
// --- A4 Der Azubi arbeitet ------------------------------------------------
|
||||
|
||||
abschnitt('A4 · Der Azubi arbeitet');
|
||||
|
||||
const azubi = await browser.newContext({ locale: 'de-DE' });
|
||||
const z = await azubi.newPage();
|
||||
z.setDefaultTimeout(60000);
|
||||
await z.goto(`${BASIS}/anmelden`);
|
||||
await z.fill('#email', AZUBI.email);
|
||||
await z.fill('#passwort', AZUBI.pw);
|
||||
await z.click('button[type=submit]');
|
||||
const angemeldet = await z.waitForURL(`${BASIS}/`, { timeout: 15000 }).then(() => true, () => false);
|
||||
pruefe(angemeldet, 'Der Azubi meldet sich mit dem vergebenen Passwort an');
|
||||
if (!angemeldet) throw new Error('Anmeldung des Azubis fehlgeschlagen.');
|
||||
|
||||
await z.goto(`${BASIS}/handouts/N3`);
|
||||
await z.waitForSelector('h1');
|
||||
await z.click('button:has-text("Als gelesen markieren")');
|
||||
await z.waitForSelector('button:has-text("Doch noch nicht gelesen")', { timeout: 15000 });
|
||||
pruefe(true, 'Ein Handout lässt sich als gelesen markieren');
|
||||
|
||||
await z.goto(`${BASIS}/karten/lernen`);
|
||||
const hatKarte = (await z.locator('.lernkarte').count()) > 0;
|
||||
if (hatKarte) {
|
||||
await z.click('.lernkarte');
|
||||
await z.click('.bewerten-sitzt');
|
||||
await z.waitForTimeout(600);
|
||||
const rueck = await z.locator('.lernmodus-rueckmeldung').innerText();
|
||||
pruefe(/kommt/.test(rueck), 'Eine Merkkarte lässt sich bewerten', rueck.trim());
|
||||
} else {
|
||||
pruefe(false, 'Eine Merkkarte lässt sich bewerten', 'kein Stapel fällig');
|
||||
}
|
||||
|
||||
// --- A5 Aufgabe abgeben und abnehmen -------------------------------------
|
||||
|
||||
abschnitt('A5 · Aufgabe abgeben und abnehmen');
|
||||
|
||||
await z.goto(`${BASIS}/aufgaben/1.1`);
|
||||
await z.waitForSelector('#aufgaben-notiz');
|
||||
|
||||
await z.fill(
|
||||
'#aufgaben-notiz',
|
||||
'Arbeitsplatz nach Checkliste übergeben: Gerät angeschlossen, Anmeldung geprüft, ' +
|
||||
'Drucker verbunden, Nutzer eingewiesen, Ticket geschlossen.',
|
||||
);
|
||||
await z.click('button:has-text("Notizen speichern")');
|
||||
await z.waitForSelector('text=/gespeichert/i', { timeout: 20000 });
|
||||
pruefe(true, 'Der Azubi hält seine Arbeit in Notizen fest');
|
||||
|
||||
await z.click('button:has-text("Zur Abnahme")');
|
||||
await z.waitForTimeout(1500);
|
||||
await z.reload();
|
||||
pruefe(
|
||||
await z.isDisabled('button:has-text("Zur Abnahme")'),
|
||||
'Der Azubi meldet die Aufgabe zur Abnahme',
|
||||
);
|
||||
|
||||
// Der Ausbilder nimmt ab.
|
||||
await a.goto(`${BASIS}/aufgaben`);
|
||||
await a.waitForSelector('h1');
|
||||
const zurAbnahme = await a.$('a[href*="/aufgaben/1.1?azubi="]');
|
||||
pruefe(Boolean(zurAbnahme), 'Die Aufgabe erscheint beim Ausbilder zur Abnahme');
|
||||
|
||||
if (zurAbnahme) {
|
||||
await zurAbnahme.click();
|
||||
await a.waitForSelector('.kriterien', { timeout: 15000 });
|
||||
|
||||
for (const kasten of await a.$$('.kriterien input[type=checkbox]')) await kasten.check();
|
||||
await a.fill('#bewertung', 'gut');
|
||||
await a.fill('#rueckmeldung', 'Sauber dokumentiert, Einweisung nachvollziehbar.');
|
||||
await a.click('button:has-text("Bewertung speichern")');
|
||||
await a.waitForSelector('text=/gespeichert/i', { timeout: 20000 });
|
||||
|
||||
await a.click('button:has-text("Aufgabe abnehmen")');
|
||||
await a.waitForSelector('button:has-text("Bereits abgenommen")', { timeout: 20000 });
|
||||
pruefe(true, 'Der Ausbilder nimmt die Aufgabe ab');
|
||||
|
||||
// Und danach ist sie für den Azubi gesperrt.
|
||||
await z.goto(`${BASIS}/aufgaben/1.1`);
|
||||
await z.waitForSelector('h1');
|
||||
pruefe(
|
||||
await z.isDisabled('#aufgaben-notiz'),
|
||||
'Für den Azubi ist die abgenommene Aufgabe gesperrt',
|
||||
);
|
||||
|
||||
// Eine abgenommene Aufgabe ist der belastbare Nachweis in der Abdeckung.
|
||||
await a.goto(`${BASIS}/auswertung`);
|
||||
await a.waitForSelector('h1');
|
||||
const abdeckung = await a.locator('body').innerText();
|
||||
pruefe(/vermittelt/.test(abdeckung), 'Die Abnahme schlägt in der Rahmenplanabdeckung durch');
|
||||
pruefe(/ohne Inhalt|kein Inhalt/i.test(abdeckung), 'Lücken im Lehrgang werden getrennt ausgewiesen');
|
||||
}
|
||||
|
||||
// --- A6 Rechte -----------------------------------------------------------
|
||||
|
||||
abschnitt('A6 · Rechte serverseitig');
|
||||
|
||||
const fremd = await azubi.request.get(`${BASIS}/verwaltung`, { maxRedirects: 0 });
|
||||
pruefe(
|
||||
fremd.status() >= 300 && fremd.status() < 400,
|
||||
'Der Azubi kommt nicht in die Verwaltung',
|
||||
`HTTP ${fremd.status()}`,
|
||||
);
|
||||
|
||||
// --- A7 Berichtsheft -----------------------------------------------------
|
||||
|
||||
abschnitt('A7 · Berichtsheft');
|
||||
|
||||
await z.goto(`${BASIS}/berichtsheft`);
|
||||
await z.waitForSelector('h1');
|
||||
|
||||
// Auf einer frischen Datenbank gibt es noch kein Blatt – der Azubi legt das
|
||||
// der laufenden Woche an. Genau so fängt er im Betrieb auch an.
|
||||
const anlegen = await z.$('button:has-text("Blatt für diese Woche anlegen")');
|
||||
if (anlegen) {
|
||||
await anlegen.click();
|
||||
await z.waitForSelector('.blattzeile-link, h1', { timeout: 30000 });
|
||||
await z.goto(`${BASIS}/berichtsheft`);
|
||||
await z.waitForSelector('h1');
|
||||
}
|
||||
pruefe(
|
||||
(await z.locator('.blattzeile-link').count()) > 0,
|
||||
'Der Azubi legt das Wochenblatt der laufenden Woche an',
|
||||
);
|
||||
|
||||
const blatt = await z.$('.blattzeile-link');
|
||||
if (blatt) {
|
||||
await blatt.click();
|
||||
// Auf die Adresse warten, nicht auf eine Überschrift: Die steht auch auf
|
||||
// der Liste, von der wir kommen. Sonst zeigt `url()` noch dorthin – und
|
||||
// die Anfrage landet auf der Sammelroute statt auf dem einzelnen Blatt.
|
||||
await z.waitForURL(/\/berichtsheft\/[0-9a-f-]{36}$/, { timeout: 30000 });
|
||||
pruefe(true, 'Ein Wochenblatt lässt sich öffnen');
|
||||
|
||||
const pdf = await azubi.request.get(`${z.url()}/pdf`);
|
||||
const puffer = await pdf.body();
|
||||
pruefe(
|
||||
pdf.status() === 200 && puffer.subarray(0, 5).toString() === '%PDF-',
|
||||
'Das Wochenblatt kommt als PDF heraus',
|
||||
`HTTP ${pdf.status()}, ${puffer.byteLength} Bytes${
|
||||
puffer.subarray(0, 5).toString() === '%PDF-' ? '' : `: „${puffer.toString('utf8').slice(0, 60)}"`
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
pruefe(false, 'Ein Wochenblatt lässt sich öffnen', 'keine Blattzeile gefunden');
|
||||
}
|
||||
|
||||
// --- A8 Sicherung und Rückspielung ---------------------------------------
|
||||
|
||||
abschnitt('A8 · Sicherung zurückspielen');
|
||||
|
||||
// Pfade für bash mit Schrägstrichen: Ein Backslash ist dort ein Fluchtzeichen.
|
||||
const alsPosix = (pfad) => pfad.split(path.sep).join('/');
|
||||
const sicherung = laufen('bash', ['scripts/im-container/sicherung.sh', alsPosix(path.join(daten, 'sicherungen'))]);
|
||||
const archiv = (sicherung.stdout || '').trim().split('\n').pop();
|
||||
pruefe(
|
||||
sicherung.status === 0 && archiv && fs.existsSync(archiv),
|
||||
'Die Sicherung läuft durch',
|
||||
archiv ? path.basename(archiv) : (sicherung.stderr || '').trim(),
|
||||
);
|
||||
|
||||
// Jetzt absichtlich kaputtmachen: Der Azubi verschwindet. Vorher den Server
|
||||
// beenden – zurückgespielt wird bei gestoppter Anwendung, so steht es auch
|
||||
// in der Anleitung.
|
||||
serverBeenden();
|
||||
await new Promise((weiter) => setTimeout(weiter, 2500));
|
||||
|
||||
const { default: Datenbank } = await import('better-sqlite3');
|
||||
const dbPfad = path.join(daten, 'lernbar.sqlite');
|
||||
|
||||
const vorher = (() => {
|
||||
const db = new Datenbank(dbPfad);
|
||||
const zahl = db.prepare('select count(*) n from benutzer').get().n;
|
||||
db.prepare('delete from benutzer where email = ?').run(AZUBI.email);
|
||||
const nachher = db.prepare('select count(*) n from benutzer').get().n;
|
||||
db.close();
|
||||
return { zahl, nachher };
|
||||
})();
|
||||
pruefe(vorher.nachher < vorher.zahl, 'Zum Beweis wird der Datenbestand beschädigt', `${vorher.zahl} → ${vorher.nachher} Konten`);
|
||||
|
||||
const zurueck = laufen('bash', ['scripts/im-container/wiederherstellen.sh', alsPosix(archiv ?? ''), '--ohne-rueckfrage']);
|
||||
pruefe(zurueck.status === 0, 'Die Rückspielung läuft durch', (zurueck.stderr || '').trim() || 'ohne Fehler');
|
||||
|
||||
const wiederDa = (() => {
|
||||
const db = new Datenbank(dbPfad);
|
||||
const treffer = db.prepare('select name from benutzer where email = ?').get(AZUBI.email);
|
||||
const karten = db.prepare('select count(*) n from karten_verlauf').get().n;
|
||||
db.close();
|
||||
return { treffer, karten };
|
||||
})();
|
||||
pruefe(wiederDa.treffer?.name === AZUBI.name, 'Der gelöschte Azubi ist wieder da', wiederDa.treffer?.name ?? 'fehlt');
|
||||
pruefe(wiederDa.karten >= 0, 'Auch der Lernverlauf kam mit zurück', `${wiederDa.karten} Einträge`);
|
||||
|
||||
const verdraengt = fs.readdirSync(daten).filter((name) => name.startsWith('verdraengt-'));
|
||||
pruefe(verdraengt.length === 1, 'Der überschriebene Stand wurde zur Seite gelegt, nicht gelöscht', verdraengt[0] ?? '—');
|
||||
} catch (ausnahme) {
|
||||
console.error('\nAbbruch:', ausnahme instanceof Error ? ausnahme.message : ausnahme);
|
||||
fehler += 1;
|
||||
} finally {
|
||||
await browser?.close();
|
||||
}
|
||||
|
||||
// --- Protokoll -------------------------------------------------------------
|
||||
|
||||
const zeilen = ['# Abnahmeprotokoll', '', `Erzeugt am ${new Date().toISOString().slice(0, 16).replace('T', ' ')} Uhr`, '', 'Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenbank**.', ''];
|
||||
for (const eintrag of protokoll) {
|
||||
if (eintrag.abschnitt) zeilen.push('', `## ${eintrag.abschnitt}`, '');
|
||||
else zeilen.push(`- ${eintrag.erfuellt ? '✓' : '✗'} ${eintrag.text}${eintrag.zusatz ? ` — ${eintrag.zusatz}` : ''}`);
|
||||
}
|
||||
zeilen.push('', `**${protokoll.filter((e) => e.erfuellt).length} von ${protokoll.filter((e) => 'erfuellt' in e).length} Prüfungen erfüllt.**`, '');
|
||||
|
||||
fs.writeFileSync(path.join(process.cwd(), 'docs', 'ABNAHME.md'), zeilen.join('\n'), 'utf8');
|
||||
console.log('\nProtokoll: docs/ABNAHME.md');
|
||||
|
||||
console.log(`${fehler === 0 ? '✓ Abnahme bestanden.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
|
||||
process.exit(fehler === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,292 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Durchstich für Baustein 10 – PWA, Offlinebetrieb, Druck.
|
||||
*
|
||||
* Die Zusage dieses Bausteins ist schmal und genau: Der Azubi soll ohne Netz
|
||||
* weiterlernen können, und was er dabei bewertet, darf nicht verloren gehen.
|
||||
* Gleichzeitig darf **nichts** offline liegen, was hinter einer eigenen
|
||||
* Rechteprüfung steht – Musterlösungen und Bewertungen gehören nicht in den
|
||||
* Browserspeicher.
|
||||
*
|
||||
* Geprüft wird gegen den laufenden Server, mit echtem Abschalten des Netzes.
|
||||
*/
|
||||
|
||||
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
|
||||
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
|
||||
|
||||
let fehler = 0;
|
||||
function pruefe(bedingung, text, zusatz = '') {
|
||||
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
|
||||
if (!bedingung) fehler += 1;
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const kontext = await browser.newContext({ locale: 'de-DE' });
|
||||
const seite = await kontext.newPage();
|
||||
|
||||
async function anmelden() {
|
||||
await seite.goto(`${BASIS}/anmelden`);
|
||||
await seite.fill('#email', AZUBI.email);
|
||||
await seite.fill('#passwort', AZUBI.pw);
|
||||
await seite.click('button[type=submit]');
|
||||
await seite.waitForURL(`${BASIS}/`);
|
||||
}
|
||||
|
||||
/** Wartet, bis der Dienstarbeiter das Ruder übernommen hat. */
|
||||
async function dienstarbeiterBereit() {
|
||||
return seite.evaluate(async () => {
|
||||
if (!('serviceWorker' in navigator)) return false;
|
||||
const registrierung = await navigator.serviceWorker.ready;
|
||||
// `ready` sagt nur „installiert". Erst ein Controller fängt Anfragen ab.
|
||||
if (navigator.serviceWorker.controller) return true;
|
||||
await new Promise((fertig) => {
|
||||
navigator.serviceWorker.addEventListener('controllerchange', fertig, { once: true });
|
||||
window.setTimeout(fertig, 5000);
|
||||
});
|
||||
return Boolean(navigator.serviceWorker.controller || registrierung.active);
|
||||
});
|
||||
}
|
||||
|
||||
// --- 1. Die Anwendung lässt sich installieren ------------------------------
|
||||
|
||||
console.log('\n1. Installierbar');
|
||||
|
||||
const manifest = await kontext.request.get(`${BASIS}/manifest.webmanifest`);
|
||||
pruefe(manifest.status() === 200, 'Das Manifest wird ausgeliefert', `HTTP ${manifest.status()}`);
|
||||
|
||||
const manifestInhalt = await manifest.json().catch(() => null);
|
||||
pruefe(manifestInhalt?.name?.includes('Lern-Bar'), 'Es trägt den Namen der Anwendung');
|
||||
pruefe(manifestInhalt?.display === 'standalone', 'Es startet als eigenständige Anwendung');
|
||||
pruefe(
|
||||
Array.isArray(manifestInhalt?.icons) && manifestInhalt.icons.some((s) => s.purpose === 'maskable'),
|
||||
'Es enthält ein Symbol für den Beschnitt unter Android',
|
||||
);
|
||||
|
||||
for (const datei of ['/symbol-192.png', '/symbol-512.png', '/symbol-maskabel-512.png', '/sw.js']) {
|
||||
const antwort = await kontext.request.get(`${BASIS}${datei}`);
|
||||
pruefe(antwort.status() === 200, `${datei} liegt bereit`, `HTTP ${antwort.status()}`);
|
||||
}
|
||||
|
||||
// Nichts davon darf von außen kommen – die Anwendung läuft in einem Netz
|
||||
// ohne Internet.
|
||||
await anmelden();
|
||||
const fremdeQuellen = await seite.evaluate(() =>
|
||||
[...document.querySelectorAll('script[src], link[href], img[src]')]
|
||||
.map((element) => element.getAttribute('src') ?? element.getAttribute('href') ?? '')
|
||||
.filter((adresse) => /^https?:\/\//.test(adresse) && !adresse.startsWith(location.origin)),
|
||||
);
|
||||
pruefe(fremdeQuellen.length === 0, 'Keine Quelle von außerhalb', fremdeQuellen.join(', ') || 'keine');
|
||||
|
||||
// --- 2. Der Dienstarbeiter übernimmt ---------------------------------------
|
||||
|
||||
console.log('\n2. Dienstarbeiter');
|
||||
|
||||
pruefe(await dienstarbeiterBereit(), 'Der Dienstarbeiter ist eingerichtet und steuert die Seite');
|
||||
|
||||
// Lernmaterial einmal ansehen, damit es im Speicher landet.
|
||||
await seite.goto(`${BASIS}/handouts/N3`);
|
||||
await seite.waitForSelector('h1');
|
||||
await seite.goto(`${BASIS}/karten/lernen`);
|
||||
await seite.waitForSelector('h1, .lernkarte');
|
||||
|
||||
// Und etwas, das **nicht** in den Speicher darf.
|
||||
await seite.goto(`${BASIS}/kontrollen`);
|
||||
await seite.waitForSelector('h1');
|
||||
|
||||
const imSpeicher = await seite.evaluate(async () => {
|
||||
const namen = await caches.keys();
|
||||
const pfade = [];
|
||||
for (const name of namen) {
|
||||
const speicher = await caches.open(name);
|
||||
for (const anfrage of await speicher.keys()) pfade.push(new URL(anfrage.url).pathname);
|
||||
}
|
||||
return pfade;
|
||||
});
|
||||
|
||||
pruefe(imSpeicher.includes('/handouts/N3'), 'Das gelesene Handout liegt im Speicher');
|
||||
pruefe(imSpeicher.includes('/karten/lernen'), 'Der Lernmodus liegt im Speicher');
|
||||
|
||||
// Das ist die eigentliche Zusage – hier wird eine Regel geprüft, keine Laune.
|
||||
const verboten = imSpeicher.filter((pfad) =>
|
||||
/^\/(kontrollen|berichtsheft|feedback|auswertung|aufgaben|checklisten|verwaltung|anhaenge)/.test(pfad),
|
||||
);
|
||||
pruefe(
|
||||
verboten.length === 0,
|
||||
'Nichts Bewertungsrelevantes liegt im Browserspeicher',
|
||||
verboten.join(', ') || 'nichts',
|
||||
);
|
||||
pruefe(
|
||||
!imSpeicher.some((pfad) => pfad.endsWith('/pdf')),
|
||||
'Kein PDF liegt im Browserspeicher',
|
||||
);
|
||||
|
||||
// --- 3. Ohne Netz --------------------------------------------------------
|
||||
|
||||
console.log('\n3. Ohne Netz');
|
||||
|
||||
await kontext.setOffline(true);
|
||||
|
||||
const handoutOffline = await seite.goto(`${BASIS}/handouts/N3`).catch(() => null);
|
||||
pruefe(handoutOffline !== null && handoutOffline.ok(), 'Das gelesene Handout öffnet sich ohne Netz');
|
||||
pruefe(
|
||||
(await seite.locator('h1').innerText().catch(() => '')).length > 0,
|
||||
'Es ist wirklich der Inhalt und keine Fehlerseite',
|
||||
await seite.locator('h1').innerText().catch(() => '—'),
|
||||
);
|
||||
|
||||
const lernenOffline = await seite.goto(`${BASIS}/karten/lernen`).catch(() => null);
|
||||
pruefe(lernenOffline !== null && lernenOffline.ok(), 'Der Lernmodus öffnet sich ohne Netz');
|
||||
|
||||
// Die Netzleiste sagt Bescheid.
|
||||
await seite.waitForTimeout(500);
|
||||
const leiste = await seite.locator('.netzleiste').count();
|
||||
pruefe(leiste > 0, 'Die Leiste „Ohne Netz" erscheint');
|
||||
|
||||
// Eine Seite, die nie im Speicher war, landet auf der Offlineseite.
|
||||
const unbekannt = await seite.goto(`${BASIS}/kontrollen`).catch(() => null);
|
||||
const unbekannterText = await seite.locator('body').innerText().catch(() => '');
|
||||
pruefe(
|
||||
unbekannterText.includes('Kein Netz'),
|
||||
'Eine nicht gespeicherte Seite zeigt die Offlineseite',
|
||||
`HTTP ${unbekannt?.status() ?? '—'}`,
|
||||
);
|
||||
|
||||
// --- 4. Bewertungen gehen nicht verloren -----------------------------------
|
||||
|
||||
console.log('\n4. Bewertungen ohne Netz');
|
||||
|
||||
await seite.goto(`${BASIS}/karten/lernen`);
|
||||
const hatKarte = (await seite.locator('.lernkarte').count()) > 0;
|
||||
|
||||
if (!hatKarte) {
|
||||
console.log(' – heute ist keine Karte fällig, Warteschlange übersprungen');
|
||||
} else {
|
||||
await seite.click('.lernkarte');
|
||||
await seite.waitForSelector('.bewerten-knopf');
|
||||
await seite.click('.bewerten-sitzt');
|
||||
await seite.waitForTimeout(800);
|
||||
|
||||
const schlange = await seite.evaluate(() =>
|
||||
Object.keys(localStorage)
|
||||
.filter((s) => s.startsWith('lernbar-warteschlange-'))
|
||||
.flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')),
|
||||
);
|
||||
pruefe(schlange.length >= 1, 'Die Bewertung liegt in der Warteschlange', `${schlange.length} Eintrag/Einträge`);
|
||||
pruefe(
|
||||
schlange.every((e) => e.karteId && e.bewertung && /^\d{4}-\d{2}-\d{2}$/.test(e.erfasstAm)),
|
||||
'Jeder Eintrag trägt Karte, Bewertung und den Tag der Bewertung',
|
||||
);
|
||||
|
||||
const gemeldet = await seite.locator('.lernmodus-rueckmeldung').innerText();
|
||||
pruefe(/gemerkt/.test(gemeldet), 'Der Azubi sieht, dass die Bewertung gemerkt wurde', gemeldet.trim());
|
||||
|
||||
// --- Und wieder im Netz ---
|
||||
console.log('\n5. Wieder im Netz');
|
||||
|
||||
// Wichtig: auf **derselben** Seite bleiben. Die Verbindung kehrt zurück,
|
||||
// während der Azubi noch im Lernmodus steht – genau dort muss die
|
||||
// Bestätigung erscheinen. Wer vorher die Seite wechselt, sieht sie nie,
|
||||
// weil die Schlange dann schon leer ist.
|
||||
await kontext.setOffline(false);
|
||||
|
||||
const bestaetigung = await seite
|
||||
.locator('.netzleiste')
|
||||
.filter({ hasText: 'nachgereicht' })
|
||||
.first()
|
||||
.innerText({ timeout: 15000 })
|
||||
.catch(() => '');
|
||||
pruefe(/nachgereicht/.test(bestaetigung), 'Der Azubi bekommt die Bestätigung zu sehen', bestaetigung.trim() || '—');
|
||||
|
||||
const nachher = await seite.evaluate(() =>
|
||||
Object.keys(localStorage)
|
||||
.filter((s) => s.startsWith('lernbar-warteschlange-'))
|
||||
.flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')),
|
||||
);
|
||||
pruefe(nachher.length === 0, 'Die Warteschlange ist abgearbeitet', `${nachher.length} übrig`);
|
||||
|
||||
// Und die Bewertung ist wirklich angekommen, nicht nur verschwunden.
|
||||
await seite.goto(`${BASIS}/karten`);
|
||||
await seite.waitForSelector('h1');
|
||||
const uebersicht = await seite.locator('body').innerText();
|
||||
pruefe(
|
||||
!/0 von \d+ Karten/.test(uebersicht) || /sitzt/i.test(uebersicht),
|
||||
'Die Übersicht kennt den neuen Lernstand',
|
||||
);
|
||||
}
|
||||
|
||||
await kontext.setOffline(false);
|
||||
|
||||
// --- 6. Die Schnittstelle prüft die Rechte ---------------------------------
|
||||
|
||||
console.log('\n6. Rechte an der Schnittstelle');
|
||||
|
||||
const ohneAnmeldung = await browser.newContext();
|
||||
const abgewiesen = await ohneAnmeldung.request.post(`${BASIS}/api/lernstand`, {
|
||||
data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] },
|
||||
});
|
||||
pruefe(abgewiesen.status() === 401, 'Ohne Anmeldung: abgewiesen', `HTTP ${abgewiesen.status()}`);
|
||||
await ohneAnmeldung.close();
|
||||
|
||||
const alsAusbilder = await browser.newContext({ locale: 'de-DE' });
|
||||
const ausbilderSeite = await alsAusbilder.newPage();
|
||||
await ausbilderSeite.goto(`${BASIS}/anmelden`);
|
||||
await ausbilderSeite.fill('#email', 'fw@wildeflorian.de');
|
||||
await ausbilderSeite.fill('#passwort', 'ausbilder-start');
|
||||
await ausbilderSeite.click('button[type=submit]');
|
||||
await ausbilderSeite.waitForURL(`${BASIS}/`);
|
||||
|
||||
const alsAusbilderAbgewiesen = await alsAusbilder.request.post(`${BASIS}/api/lernstand`, {
|
||||
data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] },
|
||||
});
|
||||
pruefe(
|
||||
alsAusbilderAbgewiesen.status() === 403,
|
||||
'Der Ausbilder darf den Lernstand nicht führen',
|
||||
`HTTP ${alsAusbilderAbgewiesen.status()}`,
|
||||
);
|
||||
await alsAusbilder.close();
|
||||
|
||||
// --- 7. Druck --------------------------------------------------------------
|
||||
|
||||
console.log('\n7. Druck');
|
||||
|
||||
await seite.goto(`${BASIS}/handouts/N3`);
|
||||
await seite.waitForSelector('h1');
|
||||
|
||||
// Die dunkle Fassung einstellen – und dann drucken. Das ist der Fall, in dem
|
||||
// früher schwarze Flächen aufs Papier gekommen wären.
|
||||
await seite.evaluate(() => {
|
||||
document.documentElement.setAttribute('data-thema', 'dunkel');
|
||||
localStorage.setItem('lernbar-thema', 'dunkel');
|
||||
});
|
||||
await seite.emulateMedia({ media: 'print' });
|
||||
await seite.waitForTimeout(200);
|
||||
|
||||
const druckfarben = await seite.evaluate(() => {
|
||||
const stil = getComputedStyle(document.body);
|
||||
const wurzel = getComputedStyle(document.documentElement);
|
||||
return { grund: stil.backgroundColor, text: stil.color, flaeche: wurzel.getPropertyValue('--flaeche').trim() };
|
||||
});
|
||||
pruefe(
|
||||
druckfarben.grund === 'rgb(255, 255, 255)',
|
||||
'Im Druck ist der Grund weiß, auch bei eingestellter dunkler Fassung',
|
||||
druckfarben.grund,
|
||||
);
|
||||
pruefe(druckfarben.text === 'rgb(0, 0, 0)', 'Der Text ist schwarz', druckfarben.text);
|
||||
pruefe(
|
||||
druckfarben.flaeche.toLowerCase() === '#f2f2f2',
|
||||
'Auch die Flächen sind hell',
|
||||
druckfarben.flaeche,
|
||||
);
|
||||
|
||||
const versteckt = await seite.evaluate(() => {
|
||||
const kopf = document.querySelector('.kopf');
|
||||
return kopf ? getComputedStyle(kopf).display : 'keine';
|
||||
});
|
||||
pruefe(versteckt === 'none', 'Die Navigation wird nicht mitgedruckt', versteckt);
|
||||
|
||||
await seite.emulateMedia({ media: null });
|
||||
await browser.close();
|
||||
|
||||
console.log(`\n${fehler === 0 ? '✓ Baustein 10 steht.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
|
||||
process.exit(fehler === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,237 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Durchstich für Baustein 9.
|
||||
*
|
||||
* Geprüft werden die beiden Zusagen, die in diesem Baustein zählen:
|
||||
*
|
||||
* 1. Die Rahmenplanabdeckung unterscheidet vier Lagen – und weist Positionen
|
||||
* ohne Material im Lehrgang getrennt aus (A4, A10, C1, C2, C5).
|
||||
* 2. Das Feedbackgespräch bewertet beide Seiten getrennt, hebt Abweichungen
|
||||
* hervor, ist nach beidseitiger Bestätigung unveränderlich und liefert
|
||||
* ein PDF.
|
||||
*/
|
||||
|
||||
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
|
||||
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
|
||||
const AUSBILDER = { email: 'fw@wildeflorian.de', pw: 'ausbilder-start' };
|
||||
|
||||
let fehler = 0;
|
||||
function pruefe(bedingung, text, zusatz = '') {
|
||||
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
|
||||
if (!bedingung) fehler += 1;
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
const UEBRIGE = [
|
||||
'sorgfalt', 'dokumentation', 'fachwissen', 'fehlersuche',
|
||||
'kundenkontakt', 'zuverlaessigkeit', 'lernbereitschaft',
|
||||
];
|
||||
|
||||
/**
|
||||
* Die Radios selbst sind für das Auge weggeblendet; angeklickt wird die
|
||||
* Beschriftung – genau wie ein Mensch es täte.
|
||||
*/
|
||||
async function waehle(seite, kriterium, wert) {
|
||||
await seite.click(`label.stufe:has(input[name="stufe-${kriterium}"][value="${wert}"])`);
|
||||
}
|
||||
|
||||
async function neueSitzung(konto) {
|
||||
const kontext = await browser.newContext({ locale: 'de-DE' });
|
||||
const seite = await kontext.newPage();
|
||||
await seite.goto(`${BASIS}/anmelden`);
|
||||
await seite.fill('#email', konto.email);
|
||||
await seite.fill('#passwort', konto.pw);
|
||||
await seite.click('button[type=submit]');
|
||||
await seite.waitForURL(`${BASIS}/`);
|
||||
return { kontext, seite };
|
||||
}
|
||||
|
||||
// --- 1. Auswertung ---------------------------------------------------------
|
||||
|
||||
console.log('\n1. Auswertung und Rahmenplanabdeckung (Ausbilder)');
|
||||
|
||||
const ausbilder = await neueSitzung(AUSBILDER);
|
||||
await ausbilder.seite.goto(`${BASIS}/auswertung`);
|
||||
await ausbilder.seite.waitForLoadState('networkidle');
|
||||
|
||||
const text = await ausbilder.seite.locator('body').innerText();
|
||||
|
||||
pruefe(text.includes('Abdeckung'), 'Die Auswertung zeigt die Rahmenplanabdeckung');
|
||||
pruefe(/ohne Inhalt/i.test(text), 'Die vierte Lage „ohne Inhalt" kommt vor');
|
||||
|
||||
// Die fünf Lücken im Beispielinhalt müssen als eigene Gruppe auftauchen.
|
||||
const luecken = ['A4', 'A10', 'C1', 'C2', 'C5'];
|
||||
const gefunden = [];
|
||||
for (const kuerzel of luecken) {
|
||||
const treffer = await ausbilder.seite
|
||||
.locator(`[data-stufe="ohne_inhalt"]:has-text("${kuerzel}")`)
|
||||
.count()
|
||||
.catch(() => 0);
|
||||
if (treffer > 0) gefunden.push(kuerzel);
|
||||
}
|
||||
pruefe(
|
||||
gefunden.length === luecken.length,
|
||||
'Alle fünf Positionen ohne Material sind als Lücke im Lehrgang gekennzeichnet',
|
||||
`gefunden: ${gefunden.join(', ') || 'keine'}`,
|
||||
);
|
||||
|
||||
pruefe(/Lernfeld/i.test(text), 'Die Lernfeldabdeckung ist vorhanden');
|
||||
pruefe(/§|Verordnung|Abschnitt/i.test(text), 'Die Fundstelle in der Verordnung wird genannt');
|
||||
|
||||
// --- 2. Feedbackgespräch anlegen ------------------------------------------
|
||||
|
||||
console.log('\n2. Feedbackgespräch – Ausbilder legt an und bewertet');
|
||||
|
||||
await ausbilder.seite.goto(`${BASIS}/feedback`);
|
||||
await ausbilder.seite.click('button:has-text("Neues Gespräch anlegen")');
|
||||
await ausbilder.seite.waitForURL(/\/feedback\/[0-9a-f-]{36}$/);
|
||||
const gespraechUrl = ausbilder.seite.url();
|
||||
const gespraechId = gespraechUrl.split('/').pop();
|
||||
pruefe(Boolean(gespraechId), 'Gespräch angelegt', gespraechId);
|
||||
|
||||
// Ausbilder bewertet: Selbstständigkeit 2, alle anderen 4.
|
||||
await waehle(ausbilder.seite, 'selbststaendigkeit', 2);
|
||||
for (const k of UEBRIGE) await waehle(ausbilder.seite, k, 4);
|
||||
await ausbilder.seite.fill('#staerken', 'Arbeitet ruhig und fragt nach, bevor etwas kaputtgeht.');
|
||||
await ausbilder.seite.fill('#entwicklungsfelder', 'Bei unklarer Lage zu lange allein – früher melden.');
|
||||
await ausbilder.seite.fill('#ziele', 'Bis Ende November eine Rücksicherung eigenständig durchführen.');
|
||||
await ausbilder.seite.click('button:has-text("Speichern")');
|
||||
await ausbilder.seite.waitForSelector('text=Gespeichert', { timeout: 15000 });
|
||||
pruefe(true, 'Einschätzung des Ausbilders gespeichert');
|
||||
|
||||
// --- 3. Azubi sieht das Gespräch und schätzt sich selbst ein ---------------
|
||||
|
||||
console.log('\n3. Feedbackgespräch – Azubi schätzt sich selbst ein');
|
||||
|
||||
const azubi = await neueSitzung(AZUBI);
|
||||
await azubi.seite.goto(gespraechUrl);
|
||||
await azubi.seite.waitForLoadState('networkidle');
|
||||
|
||||
const azubiText = await azubi.seite.locator('body').innerText();
|
||||
pruefe(azubiText.includes('Feedbackgespräch'), 'Der Azubi sieht sein Gespräch');
|
||||
pruefe(
|
||||
azubiText.includes('Arbeitet ruhig und fragt nach'),
|
||||
'Der Azubi liest die Freitexte des Ausbilders',
|
||||
);
|
||||
pruefe(
|
||||
(await azubi.seite.locator('#staerken').count()) === 0,
|
||||
'Der Azubi kann die Freitexte nicht bearbeiten',
|
||||
);
|
||||
|
||||
// Azubi schätzt sich bei Selbstständigkeit deutlich höher ein: 5 gegen 2.
|
||||
await waehle(azubi.seite, 'selbststaendigkeit', 5);
|
||||
for (const k of UEBRIGE) await waehle(azubi.seite, k, 4);
|
||||
await azubi.seite.click('button:has-text("Speichern")');
|
||||
await azubi.seite.waitForSelector('text=Selbsteinschätzung ist gespeichert', { timeout: 15000 });
|
||||
|
||||
await azubi.seite.reload();
|
||||
const nachSpeichern = await azubi.seite.locator('body').innerText();
|
||||
pruefe(
|
||||
/Unterschiedliche Sicht/i.test(nachSpeichern),
|
||||
'Die Abweichung von drei Stufen wird hervorgehoben',
|
||||
);
|
||||
|
||||
// Gegenprobe: Die Spalte des Ausbilders blieb unangetastet.
|
||||
await ausbilder.seite.reload();
|
||||
pruefe(
|
||||
await ausbilder.seite.isChecked('input[name="stufe-selbststaendigkeit"][value="2"]'),
|
||||
'Der Azubi hat die Einschätzung des Ausbilders nicht überschrieben',
|
||||
);
|
||||
|
||||
// --- 4. Beidseitige Bestätigung sperrt -------------------------------------
|
||||
|
||||
console.log('\n4. Bestätigung durch beide Seiten');
|
||||
|
||||
await azubi.seite.click('button:has-text("Gespräch bestätigen")');
|
||||
await azubi.seite.waitForSelector('text=Bestätigt', { timeout: 15000 });
|
||||
pruefe(true, 'Azubi hat bestätigt');
|
||||
|
||||
// Erst ohne, dann mit Neuladen – so ist ein Rennen von einem echten Fehler
|
||||
// zu unterscheiden.
|
||||
const sofort = await azubi.seite
|
||||
.locator('button:has-text("Bestätigung zurücknehmen")')
|
||||
.waitFor({ timeout: 5000 })
|
||||
.then(() => true, () => false);
|
||||
await azubi.seite.reload();
|
||||
const nachLaden = (await azubi.seite.locator('button:has-text("Bestätigung zurücknehmen")').count()) > 0;
|
||||
pruefe(
|
||||
nachLaden,
|
||||
'Solange nur eine Seite bestätigt hat, lässt sich das zurücknehmen',
|
||||
sofort ? 'sofort sichtbar' : 'erst nach dem Neuladen sichtbar',
|
||||
);
|
||||
|
||||
await ausbilder.seite.reload();
|
||||
await ausbilder.seite.click('button:has-text("Als Ausbilder bestätigen")');
|
||||
await ausbilder.seite.waitForSelector('text=unveränderlich', { timeout: 15000 });
|
||||
|
||||
await ausbilder.seite.reload();
|
||||
const gesperrt = await ausbilder.seite.locator('body').innerText();
|
||||
pruefe(/abgeschlossen und unveränderlich/.test(gesperrt), 'Das Gespräch ist jetzt abgeschlossen');
|
||||
pruefe(
|
||||
(await ausbilder.seite.locator('button:has-text("Speichern")').count()) === 0,
|
||||
'Es gibt keinen Speichern-Knopf mehr',
|
||||
);
|
||||
pruefe(
|
||||
(await ausbilder.seite.locator('button:has-text("Bestätigung zurücknehmen")').count()) === 0,
|
||||
'Die Bestätigung lässt sich nicht mehr zurücknehmen',
|
||||
);
|
||||
|
||||
// Serverseitige Gegenprobe: Schreiben trotz Sperre muss abgewiesen werden.
|
||||
await azubi.seite.reload();
|
||||
pruefe(
|
||||
await azubi.seite.isDisabled('input[name="stufe-selbststaendigkeit"][value="5"]'),
|
||||
'Auch beim Azubi sind die Felder gesperrt',
|
||||
);
|
||||
|
||||
// --- 5. PDF ----------------------------------------------------------------
|
||||
|
||||
console.log('\n5. PDF für die Akte');
|
||||
|
||||
const pdfAntwort = await ausbilder.kontext.request.get(`${gespraechUrl}/pdf`);
|
||||
const pdfKoerper = await pdfAntwort.body();
|
||||
pruefe(pdfAntwort.status() === 200, 'PDF wird ausgeliefert', `HTTP ${pdfAntwort.status()}`);
|
||||
pruefe(pdfKoerper.subarray(0, 5).toString() === '%PDF-', 'Die Antwort ist wirklich ein PDF');
|
||||
pruefe(pdfKoerper.byteLength > 3000, 'Das PDF hat Inhalt', `${pdfKoerper.byteLength} Bytes`);
|
||||
|
||||
// Und ohne Anmeldung? Ein PDF aus der Ausbildungsakte darf nicht herausfallen.
|
||||
const fremd = await browser.newContext();
|
||||
const ohne = await fremd.request.get(`${gespraechUrl}/pdf`, { maxRedirects: 0 });
|
||||
pruefe(
|
||||
ohne.status() >= 300 && ohne.status() < 400,
|
||||
'Ohne Anmeldung kommt kein PDF heraus',
|
||||
`HTTP ${ohne.status()} → ${ohne.headers()['location'] ?? '—'}`,
|
||||
);
|
||||
await fremd.close();
|
||||
|
||||
// --- 6. Fremdzugriff -------------------------------------------------------
|
||||
|
||||
console.log('\n6. Rechte');
|
||||
|
||||
const fremderAzubi = await browser.newContext({ locale: 'de-DE' });
|
||||
const fremdSeite = await fremderAzubi.newPage();
|
||||
await fremdSeite.goto(`${BASIS}/anmelden`);
|
||||
await fremdSeite.fill('#email', 'lea.hoffmann@betrieb.de');
|
||||
await fremdSeite.fill('#passwort', 'azubi-start-2026');
|
||||
await fremdSeite.click('button[type=submit]');
|
||||
const angemeldet = await fremdSeite.waitForURL(`${BASIS}/`, { timeout: 8000 }).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
if (angemeldet) {
|
||||
const antwort = await fremdSeite.goto(gespraechUrl);
|
||||
pruefe(antwort?.status() === 404, 'Ein anderer Azubi bekommt 404', `HTTP ${antwort?.status()}`);
|
||||
|
||||
const fremdPdf = await fremderAzubi.request.get(`${gespraechUrl}/pdf`);
|
||||
pruefe(fremdPdf.status() === 404, 'Auch das PDF bleibt verschlossen', `HTTP ${fremdPdf.status()}`);
|
||||
} else {
|
||||
console.log(' – zweites Azubikonto nicht vorhanden, Fremdzugriff übersprungen');
|
||||
}
|
||||
await fremderAzubi.close();
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`\n${fehler === 0 ? '✓ Baustein 9 steht.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
|
||||
process.exit(fehler === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,142 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Gezielte Prüfung der einen Zusage, auf die es bei den Lernzielkontrollen
|
||||
* ankommt: Die Musterlösung darf vor dem Absenden nirgends herauskommen.
|
||||
*
|
||||
* Geprüft wird gegen den **echten** Wortlaut aus der Inhaltsdatei, nicht gegen
|
||||
* ausgedachte Zeichenketten – und im rohen Quelltext samt RSC-Nutzlast, nicht
|
||||
* im sichtbaren Text.
|
||||
*/
|
||||
|
||||
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
|
||||
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
|
||||
const AUSBILDER = { email: 'fw@wildeflorian.de', pw: 'ausbilder-start' };
|
||||
|
||||
// Musterlösungen direkt aus der Datei lesen – so kann der Test nicht veralten.
|
||||
const yaml = fs.readFileSync(path.join(process.cwd(), 'content/lernzielkontrollen/woche-01.yaml'), 'utf8');
|
||||
const loesungen = [...yaml.matchAll(/musterloesung:\s*>-\s*\n((?:\s{6}.+\n)+)/g)].map((t) =>
|
||||
t[1].replace(/\s+/g, ' ').trim(),
|
||||
);
|
||||
// Aussagekräftige Bruchstücke: lang genug, um nicht zufällig vorzukommen.
|
||||
const bruchstuecke = loesungen.map((l) => l.slice(40, 110)).filter((b) => b.length > 40);
|
||||
|
||||
console.log(`${loesungen.length} Musterlösungen aus woche-01.yaml gelesen.`);
|
||||
console.log('Geprüft wird auf Bruchstücke wie:', JSON.stringify(bruchstuecke[0]?.slice(0, 60)));
|
||||
|
||||
// --- Ausgangslage herstellen ----------------------------------------------
|
||||
//
|
||||
// Der entscheidende Fall ist „freigegeben, aber noch nicht abgegeben". Den gibt
|
||||
// es nur einmal je Kontrolle. Damit die Prüfung wiederholbar bleibt, wird die
|
||||
// Kontrolle „woche-01" vorher zurückgesetzt – das ist ein Testaufbau, kein Teil
|
||||
// der Anwendung, und greift deshalb ausdrücklich nur auf die lokale
|
||||
// Beispieldatenbank zu.
|
||||
const { default: Datenbank } = await import('better-sqlite3');
|
||||
const dbPfad = process.env.LERNBAR_DB ?? path.join(process.cwd(), 'daten', 'lernbar.sqlite');
|
||||
|
||||
if (fs.existsSync(dbPfad)) {
|
||||
const db = new Datenbank(dbPfad);
|
||||
const betroffen = db.prepare("select id from kontrolle where kontrolle_id = 'woche-01'").all();
|
||||
for (const eintrag of betroffen) {
|
||||
db.prepare('delete from kontroll_antwort where kontrolle_id = ?').run(eintrag.id);
|
||||
db.prepare('delete from kontrolle where id = ?').run(eintrag.id);
|
||||
}
|
||||
db.close();
|
||||
console.log(`Ausgangslage hergestellt: ${betroffen.length} frühere Bearbeitung(en) von woche-01 entfernt.`);
|
||||
} else {
|
||||
console.log(`Keine Datenbank unter ${dbPfad} – die Ausgangslage wird nicht zurückgesetzt.`);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const seite = await (await browser.newContext({ locale: 'de-DE' })).newPage();
|
||||
|
||||
async function anmelden(k) {
|
||||
await seite.goto(`${BASIS}/anmelden`);
|
||||
await seite.fill('#email', k.email);
|
||||
await seite.fill('#passwort', k.pw);
|
||||
await seite.click('button[type=submit]');
|
||||
await seite.waitForURL(`${BASIS}/`);
|
||||
}
|
||||
async function abmelden() {
|
||||
await seite.click('button:has-text("Abmelden")');
|
||||
await seite.waitForURL(/anmelden/);
|
||||
}
|
||||
|
||||
/** Sucht im rohen HTML **und** in der Server-Nutzlast. */
|
||||
async function lecksuche(beschriftung) {
|
||||
const html = await seite.content();
|
||||
|
||||
// Die Nutzlast steht in eingebetteten Skripten, die `self.__next_f` füllen.
|
||||
// Dort **nachträglich** nachzusehen bringt nichts: Next leert das Feld, sobald
|
||||
// es verarbeitet ist – es wäre immer leer, und die Prüfung liefe ins Leere,
|
||||
// ohne dass es auffällt. Deshalb wird der Quelltext der Skripte gelesen.
|
||||
const rsc = await seite.evaluate(() =>
|
||||
[...document.querySelectorAll('script:not([src])')]
|
||||
.map((element) => element.textContent ?? '')
|
||||
.filter((text) => text.includes('__next_f'))
|
||||
.join(''),
|
||||
);
|
||||
|
||||
const treffer = bruchstuecke.filter((b) => html.includes(b) || rsc.includes(b));
|
||||
const nullFelder = (rsc.match(/musterloesung.{0,12}null/g) ?? []).length;
|
||||
|
||||
console.log(
|
||||
` ${beschriftung.padEnd(34)} Leck: ${treffer.length === 0 ? 'nein' : `JA (${treffer.length})`}` +
|
||||
` | Nutzlast ${rsc.length} Zeichen | "musterloesung":null ${nullFelder}x`,
|
||||
);
|
||||
return treffer;
|
||||
}
|
||||
|
||||
console.log('\n1) Vor der Freigabe');
|
||||
await anmelden(AZUBI);
|
||||
await seite.goto(`${BASIS}/kontrollen/woche-01`);
|
||||
await seite.waitForSelector('h1');
|
||||
await lecksuche('gesperrt');
|
||||
|
||||
console.log('\n2) Ausbilder gibt frei');
|
||||
await abmelden();
|
||||
await anmelden(AUSBILDER);
|
||||
await seite.goto(`${BASIS}/kontrollen`);
|
||||
await seite.waitForSelector('.kontrollzeile');
|
||||
const freigeben = seite
|
||||
.locator('.kontrollzeile', { hasText: 'Woche 1' })
|
||||
.locator('button:has-text("Freigeben")');
|
||||
if ((await freigeben.count()) > 0) {
|
||||
await freigeben.click();
|
||||
await seite.waitForTimeout(1500);
|
||||
} else {
|
||||
// Bei einem wiederholten Lauf ist sie schon freigegeben. Das ist kein
|
||||
// Grund abzubrechen – der entscheidende Fall kommt erst danach.
|
||||
console.log(' (war bereits freigegeben)');
|
||||
}
|
||||
|
||||
console.log('\n3) Nach der Freigabe, vor der Abgabe – der entscheidende Fall');
|
||||
await abmelden();
|
||||
await anmelden(AZUBI);
|
||||
await seite.goto(`${BASIS}/kontrollen/woche-01`);
|
||||
await seite.waitForSelector('.frage-text');
|
||||
console.log(` Fragen sichtbar: ${(await seite.$$('.frage-text')).length}`);
|
||||
const leck = await lecksuche('freigegeben, nicht abgegeben');
|
||||
|
||||
console.log('\n4) Nach dem Zwischenspeichern einer Antwort');
|
||||
await seite.locator('textarea[id^=antwort-]').first().fill('Person, Gerät, Fehlerbild, Zeitpunkt.');
|
||||
await seite.waitForTimeout(1600);
|
||||
await seite.reload();
|
||||
await seite.waitForSelector('.frage-text');
|
||||
const leck2 = await lecksuche('nach Zwischenspeichern');
|
||||
|
||||
console.log('\n5) Nach der Abgabe muss sie da sein');
|
||||
await seite.click('button:has-text("Kontrolle abgeben")');
|
||||
await seite.waitForTimeout(2500);
|
||||
await seite.reload();
|
||||
await seite.waitForSelector('h1');
|
||||
const nachher = await lecksuche('abgegeben');
|
||||
console.log(` Musterlösungen jetzt sichtbar: ${nachher.length} von ${bruchstuecke.length}`);
|
||||
|
||||
await browser.close();
|
||||
|
||||
const gescheitert = leck.length + leck2.length;
|
||||
console.log(`\n${gescheitert === 0 ? 'BESTANDEN' : 'DURCHGEFALLEN'}: vor der Abgabe ${gescheitert} Leck(s).`);
|
||||
process.exit(gescheitert === 0 && nachher.length === bruchstuecke.length ? 0 : 1);
|
||||
@@ -74,11 +74,46 @@ async function main() {
|
||||
await seite.waitForSelector('#neu-name');
|
||||
await bild(seite, '03-verwaltung');
|
||||
|
||||
await seite.goto(`${BASIS}/wochenplan`);
|
||||
await seite.waitForSelector('h1');
|
||||
await bild(seite, '04-wochenplan');
|
||||
|
||||
await seite.goto(`${BASIS}/aufgaben`);
|
||||
await seite.waitForSelector('h1');
|
||||
const abnahme = await seite.$('a[href*="/aufgaben/1.1?azubi="]');
|
||||
if (abnahme) {
|
||||
await abnahme.click();
|
||||
await seite.waitForSelector('#abnahme');
|
||||
await bild(seite, '05-aufgabe-abnahme');
|
||||
}
|
||||
|
||||
// --- Auswertung und Feedbackgespräch (Ausbildersicht) ---
|
||||
await seite.goto(`${BASIS}/auswertung`);
|
||||
await seite.waitForSelector('h1');
|
||||
await bild(seite, '06-auswertung');
|
||||
|
||||
const luecke = await seite.$('[data-stufe="ohne_inhalt"]');
|
||||
if (luecke) {
|
||||
await luecke.scrollIntoViewIfNeeded();
|
||||
await bild(seite, '07-rahmenplanabdeckung', false);
|
||||
}
|
||||
|
||||
await seite.goto(`${BASIS}/feedback`);
|
||||
await seite.waitForSelector('h1');
|
||||
await bild(seite, '08-feedback-liste');
|
||||
|
||||
const gespraech = await seite.$('.gespraechszeile');
|
||||
if (gespraech) {
|
||||
await gespraech.click();
|
||||
await seite.waitForSelector('#bogen');
|
||||
await bild(seite, '09-feedbackgespraech');
|
||||
}
|
||||
|
||||
await abmelden(seite);
|
||||
|
||||
// --- Azubi ---
|
||||
await anmelden(seite, AZUBI);
|
||||
await bild(seite, '10-azubi-start');
|
||||
await bild(seite, '10-azubi-woche');
|
||||
|
||||
await seite.goto(`${BASIS}/handouts`);
|
||||
await seite.waitForSelector('h1');
|
||||
@@ -93,9 +128,57 @@ async function main() {
|
||||
await seite.click('.pruefungsfrage > summary');
|
||||
await bild(seite, '13-handout-n3');
|
||||
|
||||
await seite.goto(`${BASIS}/karten`);
|
||||
await seite.waitForSelector('h1');
|
||||
await bild(seite, '14-merkkarten');
|
||||
|
||||
await seite.goto(`${BASIS}/aufgaben`);
|
||||
await seite.waitForSelector('h1');
|
||||
await bild(seite, '16-aufgaben-liste');
|
||||
|
||||
await seite.goto(`${BASIS}/aufgaben/1.1`);
|
||||
await seite.waitForSelector('h1');
|
||||
await bild(seite, '17-aufgabe-azubi');
|
||||
|
||||
await seite.goto(`${BASIS}/checklisten`);
|
||||
await seite.waitForSelector('h1');
|
||||
await bild(seite, '18-checklisten');
|
||||
|
||||
await seite.goto(`${BASIS}/berichtsheft`);
|
||||
await seite.waitForSelector('h1');
|
||||
await bild(seite, '21-berichtsheft');
|
||||
|
||||
await seite.goto(`${BASIS}/kontrollen`);
|
||||
await seite.waitForSelector('h1');
|
||||
await bild(seite, '23-kontrollen');
|
||||
|
||||
await seite.goto(`${BASIS}/kontrollen/woche-03`);
|
||||
await seite.waitForSelector('h1');
|
||||
await bild(seite, '24-kontrolle-ergebnis');
|
||||
|
||||
// Einzelblatt und Checklisten-Durchlauf jeweils von ihrer eigenen Liste aus –
|
||||
// vorher wurde hier auf der Kontrollseite gesucht, wo es sie gar nicht gibt.
|
||||
await seite.goto(`${BASIS}/berichtsheft`);
|
||||
await seite.waitForSelector('h1');
|
||||
const blatt = await seite.$('.blattzeile-link');
|
||||
if (blatt) {
|
||||
await blatt.click();
|
||||
await seite.waitForSelector('h1');
|
||||
await bild(seite, '22-wochenblatt');
|
||||
}
|
||||
|
||||
await seite.goto(`${BASIS}/checklisten`);
|
||||
await seite.waitForSelector('h1');
|
||||
const lauf = await seite.$('.laufzeile-haupt');
|
||||
if (lauf) {
|
||||
await lauf.click();
|
||||
await seite.waitForSelector('.schrittliste');
|
||||
await bild(seite, '19-checklisten-lauf');
|
||||
}
|
||||
|
||||
await seite.goto(`${BASIS}/glossar`);
|
||||
await seite.waitForSelector('h1');
|
||||
await bild(seite, '14-glossar');
|
||||
await bild(seite, '15-glossar');
|
||||
|
||||
// --- Dunkle Darstellung ---
|
||||
await seite.goto(`${BASIS}/handouts/N3`);
|
||||
@@ -119,6 +202,15 @@ async function main() {
|
||||
await handySeite.waitForSelector('h1');
|
||||
await bild(handySeite, '30-handy-handout');
|
||||
|
||||
await handySeite.goto(`${BASIS}/karten/lernen`);
|
||||
await handySeite.waitForSelector('.lernkarte, h1');
|
||||
if (await handySeite.$('.lernkarte')) {
|
||||
await bild(handySeite, '31-handy-lernmodus-frage', false);
|
||||
await handySeite.tap('.lernkarte');
|
||||
await handySeite.waitForSelector('.lernkarte-antwort');
|
||||
await bild(handySeite, '32-handy-lernmodus-antwort', false);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log('Fertig.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Erzeugt die Symbole für die installierte Anwendung aus dem Zeichen selbst –
|
||||
* ohne Bilddienst, ohne Netz.
|
||||
*
|
||||
* npm run symbole
|
||||
*
|
||||
* Zwei Fassungen, weil Android sie unterschiedlich behandelt:
|
||||
*
|
||||
* symbol-192.png / symbol-512.png das Zeichen, wie es ist
|
||||
* symbol-maskabel-512.png derselbe Inhalt, aber randvoll auf
|
||||
* Petrol und nach innen gerückt. Android
|
||||
* schneidet das Symbol in eine eigene
|
||||
* Form – ein Kreis, ein Tropfen, was das
|
||||
* Gerät gerade mag. Was zu weit außen
|
||||
* liegt, ist dann weg.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const ZIEL = path.join(process.cwd(), 'public');
|
||||
|
||||
/** Das Zeichen. Gleicher Inhalt wie `public/lernbar.svg`. */
|
||||
function zeichen(randAnteil: number, eckenRadius: number): string {
|
||||
// Die drei Balken liegen im Original in einem Feld von 64 × 64 Punkten.
|
||||
// `randAnteil` rückt sie nach innen, ohne den Grund zu verkleinern.
|
||||
const innen = 64 * (1 - 2 * randAnteil);
|
||||
const versatz = 64 * randAnteil;
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="${eckenRadius}" fill="#1F4E5F"/>
|
||||
<g transform="translate(${versatz} ${versatz}) scale(${innen / 64})">
|
||||
<rect x="14" y="16" width="10" height="32" rx="2" fill="#EDF3F5"/>
|
||||
<rect x="27" y="16" width="10" height="32" rx="2" fill="#EDF3F5"/>
|
||||
<rect x="40" y="16" width="10" height="32" rx="2" fill="#D98A2B"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
type Auftrag = { datei: string; groesse: number; svg: string };
|
||||
|
||||
const auftraege: Auftrag[] = [
|
||||
{ datei: 'symbol-192.png', groesse: 192, svg: zeichen(0, 12) },
|
||||
{ datei: 'symbol-512.png', groesse: 512, svg: zeichen(0, 12) },
|
||||
// Randvoll und nach innen gerückt: der Beschnitt darf nichts abschneiden.
|
||||
{ datei: 'symbol-maskabel-512.png', groesse: 512, svg: zeichen(0.14, 0) },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch();
|
||||
|
||||
for (const auftrag of auftraege) {
|
||||
const seite = await browser.newPage({
|
||||
viewport: { width: auftrag.groesse, height: auftrag.groesse },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
|
||||
await seite.setContent(
|
||||
`<!doctype html><style>html,body{margin:0;padding:0}svg{display:block;width:100vw;height:100vh}</style>${auftrag.svg}`,
|
||||
);
|
||||
|
||||
const datei = path.join(ZIEL, auftrag.datei);
|
||||
await seite.screenshot({ path: datei, omitBackground: false });
|
||||
await seite.close();
|
||||
|
||||
console.log(` public/${auftrag.datei} (${auftrag.groesse}×${auftrag.groesse}, ${fs.statSync(datei).size} Bytes)`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log('Symbole erzeugt.');
|
||||
}
|
||||
|
||||
main().catch((fehler) => {
|
||||
console.error('Die Symbole konnten nicht erzeugt werden.');
|
||||
console.error(fehler instanceof Error ? fehler.message : fehler);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import Link from 'next/link';
|
||||
import { Fortschrittsbalken } from '@/komponenten/Fortschrittsbalken';
|
||||
import type { Wochensicht } from '@/lib/planung/sicht';
|
||||
import { alsDatumText, kalenderwocheText } from '@/lib/zeit';
|
||||
|
||||
const statusText: Record<string, { text: string; ton: string; zeichen: string }> = {
|
||||
geplant: { text: 'geplant', ton: 'neutral', zeichen: '○' },
|
||||
laufend: { text: 'läuft', ton: 'warnung', zeichen: '▶' },
|
||||
abgeschlossen: { text: 'abgeschlossen', ton: 'gut', zeichen: '✓' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Wochenansicht – für den Azubi die Startseite, für den Ausbilder dieselbe
|
||||
* Woche in der Ansicht seines Azubis.
|
||||
*/
|
||||
export function WochenAnsicht({
|
||||
sicht,
|
||||
istAzubi,
|
||||
wochenLink,
|
||||
}: {
|
||||
sicht: Wochensicht;
|
||||
istAzubi: boolean;
|
||||
/** Vorlage für den Verweis auf eine andere Woche, `{nr}` wird ersetzt. */
|
||||
wochenLink: string;
|
||||
}) {
|
||||
const zurWoche = (nummer: number) => wochenLink.replace('{nr}', String(nummer));
|
||||
const { woche, planung, fortschritt, tage } = sicht;
|
||||
const status = statusText[planung.status] ?? statusText.geplant!;
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<header className="stapel-eng">
|
||||
<div className="reihe" style={{ gap: '0.6rem' }}>
|
||||
<span className="wochen-nummer">Woche {woche.nummer}</span>
|
||||
<span className="marke-status" data-ton={status.ton}>
|
||||
{status.zeichen} {status.text}
|
||||
</span>
|
||||
<span className="leise">
|
||||
{alsDatumText(planung.geplantVon)} – {alsDatumText(planung.geplantBis)} · {kalenderwocheText(planung.geplantVon)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 style={{ marginBottom: '0.2rem' }}>{woche.titel}</h1>
|
||||
<p className="leitfrage">{woche.leitfrage}</p>
|
||||
<p className="leise" style={{ marginBottom: 0 }}>{woche.ziel}</p>
|
||||
</header>
|
||||
|
||||
{planung.tatsaechlichVon && planung.tatsaechlichVon !== planung.geplantVon ? (
|
||||
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
|
||||
Tatsächlich begonnen am {alsDatumText(planung.tatsaechlichVon)}, geplant war der{' '}
|
||||
{alsDatumText(planung.geplantVon)}.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{planung.notiz ? (
|
||||
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
|
||||
<strong>Notiz des Ausbilders:</strong> {planung.notiz}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="karte stapel">
|
||||
<Fortschrittsbalken fortschritt={fortschritt} />
|
||||
|
||||
{fortschritt.offen.length > 0 ? (
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1rem', marginBottom: '0.4rem' }}>
|
||||
{istAzubi ? 'Das ist diese Woche noch offen' : 'Offen bei deinem Azubi'}
|
||||
</h2>
|
||||
<ul className="offen-liste">
|
||||
{fortschritt.offen.map((bereich) => (
|
||||
<li key={bereich.schluessel}>
|
||||
<span>{bereich.titel}</span>
|
||||
<span className="leise">
|
||||
{bereich.zaehlwerk.erledigt} von {bereich.zaehlwerk.gesamt}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : fortschritt.gesamt > 0 ? (
|
||||
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
|
||||
✓ Diese Woche ist vollständig abgearbeitet.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="tage" className="stapel">
|
||||
<h2 id="tage">Die Woche</h2>
|
||||
<ol className="wochentage">
|
||||
{tage.map((tag) => (
|
||||
<li key={tag.tag} className={`wochentag${tag.istHeute ? ' wochentag-heute' : ''}`}>
|
||||
<div className="wochentag-kopf">
|
||||
<strong>{tag.tag}</strong>
|
||||
<span className="leise">{alsDatumText(tag.datum)}</span>
|
||||
{tag.istHeute ? <span className="marke-status" data-ton="warnung">● heute</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="wochentag-inhalt">
|
||||
<div>
|
||||
<span className="wochentag-marke">Theorie</span>
|
||||
{tag.theorie.length === 0 ? (
|
||||
<span className="leise">–</span>
|
||||
) : (
|
||||
<ul className="theorie-liste">
|
||||
{tag.theorie.map((handout) => {
|
||||
const gelesen = sicht.handouts.find((h) => h.handout.id === handout.id)?.gelesen ?? false;
|
||||
return (
|
||||
<li key={handout.id}>
|
||||
<Link href={`/handouts/${handout.id}`}>
|
||||
<span className="handout-kuerzel">{handout.id}</span> {handout.titel}
|
||||
</Link>
|
||||
{gelesen ? (
|
||||
<span className="marke-status" data-ton="gut">
|
||||
✓ gelesen
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="wochentag-marke">Praxis</span>
|
||||
<span>{tag.praxis || '–'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<div className="raster">
|
||||
<section className="karte" aria-labelledby="woche-handouts">
|
||||
<h2 id="woche-handouts" style={{ fontSize: '1rem' }}>
|
||||
Handouts dieser Woche
|
||||
</h2>
|
||||
{sicht.handouts.length === 0 ? (
|
||||
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
|
||||
) : (
|
||||
<ul className="stapel-eng" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{sicht.handouts.map(({ handout, gelesen }) => (
|
||||
<li key={handout.id} className="reihe" style={{ gap: '0.5rem', flexWrap: 'nowrap' }}>
|
||||
<span className="handout-kuerzel">{handout.id}</span>
|
||||
<Link href={`/handouts/${handout.id}`} style={{ flex: 1 }}>
|
||||
{handout.titel}
|
||||
</Link>
|
||||
<span aria-label={gelesen ? 'gelesen' : 'noch nicht gelesen'} title={gelesen ? 'gelesen' : 'offen'}>
|
||||
{gelesen ? '✓' : '○'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="karte" aria-labelledby="woche-aufgaben">
|
||||
<h2 id="woche-aufgaben" style={{ fontSize: '1rem' }}>
|
||||
Praxisaufgaben
|
||||
</h2>
|
||||
{sicht.aufgaben.length === 0 ? (
|
||||
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
|
||||
) : (
|
||||
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
|
||||
{sicht.aufgaben.map((aufgabe) => (
|
||||
<li key={aufgabe.id}>
|
||||
<strong>{aufgabe.id}</strong> {aufgabe.titel}
|
||||
<br />
|
||||
<span className="leise">{aufgabe.zeitrahmen}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="karte" aria-labelledby="woche-checklisten">
|
||||
<h2 id="woche-checklisten" style={{ fontSize: '1rem' }}>
|
||||
Checklisten
|
||||
</h2>
|
||||
{sicht.checklisten.length === 0 ? (
|
||||
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
|
||||
) : (
|
||||
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
|
||||
{sicht.checklisten.map((liste) => (
|
||||
<li key={liste.id}>{liste.titel}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="karte" aria-labelledby="woche-kontrolle">
|
||||
<h2 id="woche-kontrolle" style={{ fontSize: '1rem' }}>
|
||||
Lernzielkontrolle
|
||||
</h2>
|
||||
{sicht.kontrolle ? (
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
{sicht.kontrolle.titel}
|
||||
<br />
|
||||
<span className="leise">
|
||||
{sicht.kontrolle.fragen.length} Fragen ·{' '}
|
||||
{sicht.kontrolle.fragen.reduce((summe, f) => summe + f.punkte, 0)} Punkte
|
||||
{sicht.kontrolle.dauer_minuten ? ` · ${sicht.kontrolle.dauer_minuten} Minuten` : ''}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="leise" style={{ marginBottom: 0 }}>Diese Woche keine.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Andere Wochen">
|
||||
{sicht.vorherige ? (
|
||||
<Link href={zurWoche(sicht.vorherige)} className="knopf knopf-still">
|
||||
← Woche {sicht.vorherige}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{sicht.naechste ? (
|
||||
<Link href={zurWoche(sicht.naechste)} className="knopf knopf-still">
|
||||
Woche {sicht.naechste} →
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AufgabenStatus } from '@/lib/db/schema';
|
||||
|
||||
/**
|
||||
* Status einer Praxisaufgabe. Nie nur über die Farbe – jede Stufe trägt ein
|
||||
* eigenes Zeichen und ihren Namen.
|
||||
*/
|
||||
export const statusText: Record<AufgabenStatus, { text: string; ton: string; zeichen: string }> = {
|
||||
offen: { text: 'offen', ton: 'neutral', zeichen: '○' },
|
||||
in_arbeit: { text: 'in Arbeit', ton: 'warnung', zeichen: '◐' },
|
||||
zur_abnahme: { text: 'zur Abnahme', ton: 'warnung', zeichen: '▲' },
|
||||
abgenommen: { text: 'abgenommen', ton: 'gut', zeichen: '✓' },
|
||||
};
|
||||
|
||||
export function StatusMarke({ status }: { status: AufgabenStatus }) {
|
||||
const marke = statusText[status];
|
||||
return (
|
||||
<span className="marke-status" data-ton={marke.ton}>
|
||||
{marke.zeichen} {marke.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useRef, useState } from 'react';
|
||||
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
|
||||
import {
|
||||
anhangHochladen,
|
||||
bewerten,
|
||||
eigeneNotizSpeichern,
|
||||
eigenenStatusSetzen,
|
||||
type Rueckmeldung,
|
||||
} from '../aktionen';
|
||||
import type { AufgabenStatus } from '@/lib/db/schema';
|
||||
|
||||
export function StatusWaehlen({ aufgabeId, status }: { aufgabeId: string; status: AufgabenStatus }) {
|
||||
const stufen: { wert: AufgabenStatus; text: string }[] = [
|
||||
{ wert: 'offen', text: 'Offen' },
|
||||
{ wert: 'in_arbeit', text: 'In Arbeit' },
|
||||
{ wert: 'zur_abnahme', text: 'Zur Abnahme' },
|
||||
];
|
||||
|
||||
return (
|
||||
<form action={eigenenStatusSetzen} className="statuswahl">
|
||||
<input type="hidden" name="aufgabeId" value={aufgabeId} />
|
||||
{stufen.map((stufe) => (
|
||||
<button
|
||||
key={stufe.wert}
|
||||
type="submit"
|
||||
name="status"
|
||||
value={stufe.wert}
|
||||
className={`knopf ${status === stufe.wert ? '' : 'knopf-zweit'}`}
|
||||
disabled={status === stufe.wert || status === 'abgenommen'}
|
||||
aria-pressed={status === stufe.wert}
|
||||
>
|
||||
{stufe.text}
|
||||
</button>
|
||||
))}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotizFormular({
|
||||
aufgabeId,
|
||||
notiz,
|
||||
gesperrt,
|
||||
}: {
|
||||
aufgabeId: string;
|
||||
notiz: string;
|
||||
gesperrt: boolean;
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(eigeneNotizSpeichern, {});
|
||||
|
||||
return (
|
||||
<form action={aktion} className="stapel-eng">
|
||||
<Meldung zustand={zustand} />
|
||||
<input type="hidden" name="aufgabeId" value={aufgabeId} />
|
||||
<label htmlFor="aufgaben-notiz">Deine Notizen</label>
|
||||
<textarea
|
||||
id="aufgaben-notiz"
|
||||
name="notiz"
|
||||
defaultValue={notiz}
|
||||
rows={8}
|
||||
maxLength={8000}
|
||||
disabled={gesperrt}
|
||||
placeholder="Was hast du gemacht, was ist aufgefallen, wo hakte es? Diese Notizen helfen dir später beim Berichtsheft."
|
||||
/>
|
||||
{gesperrt ? (
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Die Aufgabe ist abgenommen und damit gesperrt.
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<Absenden className="knopf knopf-zweit">Notizen speichern</Absenden>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Muss zur Prüfung auf dem Server passen – siehe lib/anhaenge/dateien.ts. */
|
||||
const HOECHSTGROESSE = 25 * 1024 * 1024;
|
||||
|
||||
export function AnhangFormular({ aufgabeId, gesperrt }: { aufgabeId: string; gesperrt: boolean }) {
|
||||
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(anhangHochladen, {});
|
||||
const [namen, setzeNamen] = useState<string[]>([]);
|
||||
const [zuGross, setzeZuGross] = useState<string[]>([]);
|
||||
const feld = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (gesperrt) return null;
|
||||
|
||||
function beiAuswahl(dateien: FileList | null) {
|
||||
const liste = Array.from(dateien ?? []);
|
||||
setzeNamen(liste.map((d) => d.name));
|
||||
// Vorab im Browser prüfen: Eine zu große Datei erst hochzuladen und dann
|
||||
// abzulehnen kostet den Azubi im Zweifel Minuten am Mobilfunk.
|
||||
setzeZuGross(liste.filter((d) => d.size > HOECHSTGROESSE).map((d) => d.name));
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
action={(daten) => {
|
||||
setzeNamen([]);
|
||||
setzeZuGross([]);
|
||||
if (feld.current) feld.current.value = '';
|
||||
return aktion(daten);
|
||||
}}
|
||||
className="stapel-eng"
|
||||
>
|
||||
<Meldung zustand={zustand} />
|
||||
<input type="hidden" name="aufgabeId" value={aufgabeId} />
|
||||
|
||||
<label htmlFor="anhang-datei">Dateien anhängen</label>
|
||||
<input
|
||||
id="anhang-datei"
|
||||
ref={feld}
|
||||
name="datei"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/heic,application/pdf,text/plain,text/csv"
|
||||
onChange={(e) => beiAuswahl(e.target.files)}
|
||||
/>
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Fotos vom Aufbau, Bildschirmfotos, ein Netzplan als PDF. Höchstens 25 MB je Datei.
|
||||
</p>
|
||||
|
||||
{zuGross.length > 0 ? (
|
||||
<p className="hinweis hinweis-fehler" role="alert" style={{ margin: 0 }}>
|
||||
Zu groß (mehr als 25 MB): {zuGross.join(', ')}. Bitte die Datei verkleinern.
|
||||
</p>
|
||||
) : namen.length > 0 ? (
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Ausgewählt: {namen.join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<Absenden className="knopf knopf-zweit" wartetext="Wird übertragen …">
|
||||
Hochladen
|
||||
</Absenden>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function BewertungsFormular({
|
||||
aufgabeId,
|
||||
azubiId,
|
||||
kriterien,
|
||||
erfuellt,
|
||||
bewertung,
|
||||
rueckmeldung,
|
||||
}: {
|
||||
aufgabeId: string;
|
||||
azubiId: string;
|
||||
kriterien: string[];
|
||||
erfuellt: number[];
|
||||
bewertung: string;
|
||||
rueckmeldung: string;
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(bewerten, {});
|
||||
const [angehakt, setzeAngehakt] = useState<number[]>(erfuellt);
|
||||
|
||||
function umschalten(stelle: number) {
|
||||
setzeAngehakt((vorher) =>
|
||||
vorher.includes(stelle) ? vorher.filter((n) => n !== stelle) : [...vorher, stelle],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={aktion} className="stapel">
|
||||
<Meldung zustand={zustand} />
|
||||
<input type="hidden" name="aufgabeId" value={aufgabeId} />
|
||||
<input type="hidden" name="azubiId" value={azubiId} />
|
||||
|
||||
<fieldset className="kriterien">
|
||||
<legend>
|
||||
Bewertungskriterien{' '}
|
||||
<span className="leise">
|
||||
({angehakt.length} von {kriterien.length} erfüllt)
|
||||
</span>
|
||||
</legend>
|
||||
|
||||
{kriterien.map((text, stelle) => (
|
||||
<label key={stelle} className="ankreuzfeld kriterium">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="kriterium"
|
||||
value={stelle}
|
||||
checked={angehakt.includes(stelle)}
|
||||
onChange={() => umschalten(stelle)}
|
||||
/>
|
||||
<span style={{ fontWeight: 400 }}>{text}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<div className="feld">
|
||||
<label htmlFor="bewertung">Note oder kurze Bewertung</label>
|
||||
<input
|
||||
id="bewertung"
|
||||
name="bewertung"
|
||||
type="text"
|
||||
maxLength={60}
|
||||
defaultValue={bewertung}
|
||||
placeholder={'z. B. "gut" oder die Note 2'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label htmlFor="rueckmeldung">Rückmeldung an den Azubi</label>
|
||||
<textarea
|
||||
id="rueckmeldung"
|
||||
name="rueckmeldung"
|
||||
rows={5}
|
||||
maxLength={4000}
|
||||
defaultValue={rueckmeldung}
|
||||
placeholder="Was war gut, was fehlt noch? Konkret genug, dass er damit weiterarbeiten kann."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Absenden className="knopf">Bewertung speichern</Absenden>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { erfordereBenutzer, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
|
||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise';
|
||||
import { anhaengeVon } from '@/lib/repos/anhaenge';
|
||||
import { erfuellteKriterien, standVon } from '@/lib/repos/aufgaben';
|
||||
import { istBild, megabyte } from '@/lib/anhaenge/dateien';
|
||||
import { alsZeitpunktText } from '@/lib/zeit';
|
||||
import { StatusMarke } from '../StatusMarke';
|
||||
import { AnhangFormular, BewertungsFormular, NotizFormular, StatusWaehlen } from './Formulare';
|
||||
import { anhangEntfernen, aufgabeAbnehmen, aufgabeZurueckgeben } from '../aktionen';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const aufgabe = inhalte().aufgabeNach.get(decodeURIComponent(id));
|
||||
return { title: aufgabe ? `${aufgabe.id} – ${aufgabe.titel}` : 'Praxisaufgabe' };
|
||||
}
|
||||
|
||||
export default async function AufgabenSeite({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ azubi?: string }>;
|
||||
}) {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const { id } = await params;
|
||||
const { azubi: azubiParameter } = await searchParams;
|
||||
|
||||
const alle = inhalte();
|
||||
const aufgabe = alle.aufgabeNach.get(decodeURIComponent(id));
|
||||
if (!aufgabe) notFound();
|
||||
|
||||
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (azubiParameter ?? '');
|
||||
if (!azubiId) notFound();
|
||||
|
||||
// Serverseitige Rechteprüfung – die Kennung aus der Adresszeile wird nie geglaubt.
|
||||
erfordereSichtAufAzubi(benutzer, azubiId);
|
||||
|
||||
const istAzubi = benutzer.rolle === 'azubi';
|
||||
const azubi = istAzubi ? benutzer : benutzerNachId(azubiId);
|
||||
if (!azubi) notFound();
|
||||
|
||||
const stand = standVon(azubiId, aufgabe.id);
|
||||
const status = stand?.status ?? 'offen';
|
||||
const gesperrt = status === 'abgenommen';
|
||||
const erfuellt = erfuellteKriterien(stand);
|
||||
const anhaenge = anhaengeVon('aufgabe', `${aufgabe.id}::${azubiId}`);
|
||||
|
||||
const lernfeld = alle.lernfeldNach.get(aufgabe.lernfeld);
|
||||
const positionen = aufgabe.rahmenplan.map((p) => alle.positionNach.get(p)).filter((p) => p !== undefined);
|
||||
const abnehmer = stand?.abgenommenVon ? benutzerNachId(stand.abgenommenVon) : null;
|
||||
|
||||
return (
|
||||
<article className="stapel">
|
||||
<nav className="leise nicht-drucken" aria-label="Wo du bist">
|
||||
<Link href="/aufgaben">Praxisaufgaben</Link>
|
||||
{!istAzubi ? ` › ${azubi.name}` : ''}
|
||||
</nav>
|
||||
|
||||
<header className="stapel-eng">
|
||||
<div className="reihe" style={{ gap: '0.6rem' }}>
|
||||
<span className="aufgaben-nummer aufgaben-nummer-gross">{aufgabe.id}</span>
|
||||
<StatusMarke status={status} />
|
||||
<span className="leise">
|
||||
Woche {aufgabe.woche} · {aufgabe.zeitrahmen}
|
||||
</span>
|
||||
</div>
|
||||
<h1 style={{ marginBottom: '0.2rem' }}>{aufgabe.titel}</h1>
|
||||
<p className="handout-worum">{aufgabe.ziel}</p>
|
||||
</header>
|
||||
|
||||
<div className="handout-zuordnung karte">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Ausbildungsrahmenplan</dt>
|
||||
<dd>
|
||||
{positionen.map((p) => (
|
||||
<span key={p.id} className="zuordnung-marke" title={`${p.titel} (${p.verordnung})`}>
|
||||
<strong>{p.id}</strong> {p.kurz}
|
||||
</span>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Lernfeld</dt>
|
||||
<dd>
|
||||
{lernfeld ? (
|
||||
<span className="zuordnung-marke">
|
||||
<strong>{lernfeld.id}</strong> {lernfeld.titel}
|
||||
</span>
|
||||
) : (
|
||||
'–'
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{aufgabe.material.length > 0 ? (
|
||||
<section className="karte" aria-labelledby="material">
|
||||
<h2 id="material" style={{ fontSize: '1rem' }}>
|
||||
Benötigtes Material
|
||||
</h2>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
{aufgabe.material.map((posten, stelle) => (
|
||||
<li key={stelle}>{posten}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{aufgabe.abschnitte.map((abschnitt, stelle) =>
|
||||
abschnitt.titel ? (
|
||||
<section key={stelle} aria-label={abschnitt.titel}>
|
||||
<h2>{abschnitt.titel}</h2>
|
||||
<div
|
||||
className="fliesstext"
|
||||
dangerouslySetInnerHTML={{ __html: verlinkeGlossar(abschnitt.html, alle.glossar) }}
|
||||
/>
|
||||
</section>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
<section className="karte stapel" aria-labelledby="kriterien-liste">
|
||||
<h2 id="kriterien-liste" style={{ marginBottom: 0 }}>
|
||||
Bewertungskriterien
|
||||
</h2>
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
{erfuellt.length} von {aufgabe.kriterien.length} erfüllt · abgehakt wird vom Ausbilder
|
||||
</p>
|
||||
<ul className="kriterienliste">
|
||||
{aufgabe.kriterien.map((text, stelle) => (
|
||||
<li key={stelle} data-erfuellt={erfuellt.includes(stelle) ? 'ja' : 'nein'}>
|
||||
<span aria-hidden="true">{erfuellt.includes(stelle) ? '✓' : '○'}</span>
|
||||
<span>{text}</span>
|
||||
<span className="nur-lesehilfe">{erfuellt.includes(stelle) ? ' – erfüllt' : ' – noch offen'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* --- Bearbeitung durch den Azubi --- */}
|
||||
|
||||
{istAzubi ? (
|
||||
<section className="karte-grund stapel nicht-drucken" aria-labelledby="bearbeitung">
|
||||
<h2 id="bearbeitung" style={{ marginBottom: 0 }}>
|
||||
Deine Bearbeitung
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<p className="leise" style={{ margin: '0 0 0.35rem' }}>
|
||||
Stand setzen
|
||||
</p>
|
||||
<StatusWaehlen aufgabeId={aufgabe.id} status={status} />
|
||||
{status === 'zur_abnahme' ? (
|
||||
<p className="hinweis hinweis-warnung" style={{ marginTop: '0.6rem', marginBottom: 0 }}>
|
||||
Abgegeben am {alsZeitpunktText(stand?.abgegebenAm)} – wartet auf die Abnahme.
|
||||
</p>
|
||||
) : null}
|
||||
{gesperrt ? (
|
||||
<p className="hinweis hinweis-gut" style={{ marginTop: '0.6rem', marginBottom: 0 }}>
|
||||
✓ Abgenommen am {alsZeitpunktText(stand?.abgenommenAm)}
|
||||
{abnehmer ? ` von ${abnehmer.name}` : ''}.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<NotizFormular aufgabeId={aufgabe.id} notiz={stand?.notiz ?? ''} gesperrt={gesperrt} />
|
||||
<AnhangFormular aufgabeId={aufgabe.id} gesperrt={gesperrt} />
|
||||
</section>
|
||||
) : (
|
||||
<section className="karte stapel" aria-labelledby="azubi-stand">
|
||||
<h2 id="azubi-stand" style={{ marginBottom: 0 }}>
|
||||
Bearbeitung von {azubi.name}
|
||||
</h2>
|
||||
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
{stand?.begonnenAm ? `Begonnen ${alsZeitpunktText(stand.begonnenAm)}` : 'Noch nicht begonnen'}
|
||||
{stand?.abgegebenAm ? ` · abgegeben ${alsZeitpunktText(stand.abgegebenAm)}` : ''}
|
||||
{stand?.abgenommenAm ? ` · abgenommen ${alsZeitpunktText(stand.abgenommenAm)}` : ''}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h3 style={{ fontSize: '0.95rem', marginBottom: '0.3rem' }}>Notizen</h3>
|
||||
{stand?.notiz ? (
|
||||
<p className="notiz-anzeige">{stand.notiz}</p>
|
||||
) : (
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Noch keine Notizen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Anhänge --- */}
|
||||
|
||||
<section className="karte stapel" aria-labelledby="anhaenge">
|
||||
<h2 id="anhaenge" style={{ marginBottom: 0 }}>
|
||||
Anhänge{anhaenge.length > 0 ? ` (${anhaenge.length})` : ''}
|
||||
</h2>
|
||||
|
||||
{anhaenge.length === 0 ? (
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
Noch nichts angehängt.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="anhangliste">
|
||||
{anhaenge.map((eintrag) => (
|
||||
<li key={eintrag.id}>
|
||||
<a href={`/anhaenge/${eintrag.id}`} target="_blank" rel="noreferrer" className="anhang-kachel">
|
||||
{istBild(eintrag.mimeTyp) ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img src={`/anhaenge/${eintrag.id}`} alt={eintrag.beschriftung ?? eintrag.dateiname} />
|
||||
) : (
|
||||
<span className="anhang-symbol" aria-hidden="true">
|
||||
{eintrag.mimeTyp === 'application/pdf' ? 'PDF' : 'TXT'}
|
||||
</span>
|
||||
)}
|
||||
<span className="anhang-name">{eintrag.beschriftung ?? eintrag.dateiname}</span>
|
||||
<span className="leise">{megabyte(eintrag.groesse)}</span>
|
||||
</a>
|
||||
|
||||
{istAzubi && !gesperrt ? (
|
||||
<form action={anhangEntfernen} className="nicht-drucken">
|
||||
<input type="hidden" name="anhangId" value={eintrag.id} />
|
||||
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
|
||||
<button type="submit" className="knopf knopf-still">
|
||||
Entfernen
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* --- Bewertung und Abnahme --- */}
|
||||
|
||||
{!istAzubi ? (
|
||||
<section className="karte-grund stapel nicht-drucken" aria-labelledby="abnahme">
|
||||
<h2 id="abnahme" style={{ marginBottom: 0 }}>
|
||||
Bewerten und abnehmen
|
||||
</h2>
|
||||
|
||||
<BewertungsFormular
|
||||
aufgabeId={aufgabe.id}
|
||||
azubiId={azubiId}
|
||||
kriterien={aufgabe.kriterien}
|
||||
erfuellt={erfuellt}
|
||||
bewertung={stand?.bewertung ?? ''}
|
||||
rueckmeldung={stand?.rueckmeldung ?? ''}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="reihe">
|
||||
<form action={aufgabeAbnehmen}>
|
||||
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
|
||||
<input type="hidden" name="azubiId" value={azubiId} />
|
||||
<button type="submit" className="knopf knopf-akzent" disabled={status === 'abgenommen'}>
|
||||
{status === 'abgenommen' ? 'Bereits abgenommen' : 'Aufgabe abnehmen'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={aufgabeZurueckgeben}>
|
||||
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
|
||||
<input type="hidden" name="azubiId" value={azubiId} />
|
||||
<button type="submit" className="knopf knopf-zweit" disabled={status === 'offen'}>
|
||||
Zur Nacharbeit zurückgeben
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
Abnehmen sperrt die Aufgabe für den Azubi. Zurückgeben setzt sie auf „in Arbeit“ – schreib in dem
|
||||
Fall vorher eine Rückmeldung, sonst weiß er nicht, was fehlt.
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* --- Ergebnis, für beide sichtbar --- */}
|
||||
|
||||
{stand?.bewertung || stand?.rueckmeldung ? (
|
||||
<section className="karte stapel-eng" aria-labelledby="ergebnis">
|
||||
<h2 id="ergebnis" style={{ marginBottom: 0 }}>
|
||||
Rückmeldung des Ausbilders
|
||||
</h2>
|
||||
{stand.bewertung ? (
|
||||
<p style={{ margin: 0 }}>
|
||||
<strong>Bewertung: {stand.bewertung}</strong>
|
||||
</p>
|
||||
) : null}
|
||||
{stand.rueckmeldung ? <p className="notiz-anzeige">{stand.rueckmeldung}</p> : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<p className="leise nicht-drucken" style={{ marginBottom: 0 }}>
|
||||
Quelle: <code>{aufgabe.datei}</code>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { erfordereBenutzer, erfordereBewertungsrecht, erfordereZugriffAufAzubi } from '@/lib/auth/rechte';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { anhangAblegen, anhangLoeschen, anhangNachId } from '@/lib/repos/anhaenge';
|
||||
import {
|
||||
abnehmen,
|
||||
bewertungSetzen,
|
||||
notizSetzen,
|
||||
statusSetzen,
|
||||
standVon,
|
||||
zurueckgeben,
|
||||
} from '@/lib/repos/aufgaben';
|
||||
import { aufgabenStatus, type AufgabenStatus } from '@/lib/db/schema';
|
||||
|
||||
export type Rueckmeldung = { fehler?: string; erfolg?: string };
|
||||
|
||||
function aktualisiere(aufgabeId: string) {
|
||||
revalidatePath('/aufgaben');
|
||||
revalidatePath(`/aufgaben/${aufgabeId}`);
|
||||
revalidatePath('/');
|
||||
}
|
||||
|
||||
function aufgabeGibtEs(aufgabeId: string): boolean {
|
||||
return inhalte().aufgabeNach.has(aufgabeId);
|
||||
}
|
||||
|
||||
// --- Der Azubi an seiner eigenen Aufgabe -----------------------------------
|
||||
|
||||
export async function eigenenStatusSetzen(formular: FormData): Promise<void> {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
if (benutzer.rolle !== 'azubi') return;
|
||||
|
||||
const aufgabeId = String(formular.get('aufgabeId') ?? '');
|
||||
const status = String(formular.get('status') ?? '') as AufgabenStatus;
|
||||
|
||||
if (!aufgabeGibtEs(aufgabeId)) return;
|
||||
if (!aufgabenStatus.includes(status)) return;
|
||||
// Abnehmen darf nur der Ausbilder.
|
||||
if (status === 'abgenommen') return;
|
||||
|
||||
statusSetzen(benutzer.id, aufgabeId, status);
|
||||
aktualisiere(aufgabeId);
|
||||
}
|
||||
|
||||
export async function eigeneNotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
if (benutzer.rolle !== 'azubi') return { fehler: 'Nur der Azubi schreibt seine eigenen Notizen.' };
|
||||
|
||||
const aufgabeId = String(formular.get('aufgabeId') ?? '');
|
||||
const notiz = String(formular.get('notiz') ?? '');
|
||||
|
||||
if (!aufgabeGibtEs(aufgabeId)) return { fehler: 'Diese Aufgabe gibt es nicht.' };
|
||||
if (notiz.length > 8000) return { fehler: 'Die Notiz ist zu lang – höchstens 8000 Zeichen.' };
|
||||
|
||||
notizSetzen(benutzer.id, aufgabeId, notiz);
|
||||
aktualisiere(aufgabeId);
|
||||
return { erfolg: 'Notiz gespeichert.' };
|
||||
}
|
||||
|
||||
export async function anhangHochladen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
if (benutzer.rolle !== 'azubi') return { fehler: 'Anhänge lädt der Azubi hoch.' };
|
||||
|
||||
const aufgabeId = String(formular.get('aufgabeId') ?? '');
|
||||
if (!aufgabeGibtEs(aufgabeId)) return { fehler: 'Diese Aufgabe gibt es nicht.' };
|
||||
|
||||
const stand = standVon(benutzer.id, aufgabeId);
|
||||
if (stand?.status === 'abgenommen') {
|
||||
return { fehler: 'Die Aufgabe ist abgenommen – sie lässt sich nicht mehr ändern.' };
|
||||
}
|
||||
|
||||
const dateien = formular.getAll('datei').filter((d): d is File => d instanceof File && d.size > 0);
|
||||
if (dateien.length === 0) return { fehler: 'Keine Datei ausgewählt.' };
|
||||
|
||||
const beschriftung = String(formular.get('beschriftung') ?? '');
|
||||
const fehler: string[] = [];
|
||||
let angelegt = 0;
|
||||
|
||||
for (const datei of dateien) {
|
||||
const ergebnis = await anhangAblegen({
|
||||
bezugTyp: 'aufgabe',
|
||||
// Der Bezug ist Aufgabe **und** Azubi – sonst sähe ein zweiter Azubi
|
||||
// die Anhänge des ersten an derselben Aufgabe.
|
||||
bezugId: `${aufgabeId}::${benutzer.id}`,
|
||||
benutzerId: benutzer.id,
|
||||
datei,
|
||||
beschriftung: dateien.length === 1 ? beschriftung : undefined,
|
||||
});
|
||||
|
||||
if ('fehler' in ergebnis) {
|
||||
fehler.push(`${datei.name}: ${ergebnis.fehler}`);
|
||||
} else {
|
||||
angelegt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
aktualisiere(aufgabeId);
|
||||
|
||||
if (angelegt === 0) return { fehler: fehler.join(' · ') };
|
||||
if (fehler.length > 0) {
|
||||
return { fehler: `${angelegt} übernommen, aber: ${fehler.join(' · ')}` };
|
||||
}
|
||||
return { erfolg: `${angelegt} ${angelegt === 1 ? 'Anhang' : 'Anhänge'} hinzugefügt.` };
|
||||
}
|
||||
|
||||
export async function anhangEntfernen(formular: FormData): Promise<void> {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const anhangId = String(formular.get('anhangId') ?? '');
|
||||
const aufgabeId = String(formular.get('aufgabeId') ?? '');
|
||||
|
||||
const eintrag = anhangNachId(anhangId);
|
||||
if (!eintrag) return;
|
||||
|
||||
// Nur der eigene Anhang, und nur solange die Aufgabe nicht abgenommen ist.
|
||||
if (eintrag.benutzerId !== benutzer.id) return;
|
||||
const stand = standVon(benutzer.id, aufgabeId);
|
||||
if (stand?.status === 'abgenommen') return;
|
||||
|
||||
await anhangLoeschen(anhangId);
|
||||
aktualisiere(aufgabeId);
|
||||
}
|
||||
|
||||
// --- Der Ausbilder an der Aufgabe seines Azubis ----------------------------
|
||||
|
||||
export async function bewerten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const ausbilder = await erfordereBenutzer();
|
||||
const azubiId = String(formular.get('azubiId') ?? '');
|
||||
const aufgabeId = String(formular.get('aufgabeId') ?? '');
|
||||
|
||||
erfordereBewertungsrecht(ausbilder, azubiId);
|
||||
|
||||
const aufgabe = inhalte().aufgabeNach.get(aufgabeId);
|
||||
if (!aufgabe) return { fehler: 'Diese Aufgabe gibt es nicht.' };
|
||||
|
||||
const kriterien = formular
|
||||
.getAll('kriterium')
|
||||
.map((wert) => Number(wert))
|
||||
.filter((n) => Number.isInteger(n) && n >= 0 && n < aufgabe.kriterien.length);
|
||||
|
||||
const bewertung = String(formular.get('bewertung') ?? '').trim();
|
||||
const rueckmeldung = String(formular.get('rueckmeldung') ?? '').trim();
|
||||
|
||||
if (bewertung.length > 60) return { fehler: 'Die Bewertung ist zu lang – höchstens 60 Zeichen.' };
|
||||
if (rueckmeldung.length > 4000) return { fehler: 'Die Rückmeldung ist zu lang – höchstens 4000 Zeichen.' };
|
||||
|
||||
bewertungSetzen(azubiId, aufgabeId, {
|
||||
kriterien,
|
||||
bewertung: bewertung || null,
|
||||
rueckmeldung: rueckmeldung || null,
|
||||
});
|
||||
|
||||
aktualisiere(aufgabeId);
|
||||
return { erfolg: `Bewertung gespeichert – ${kriterien.length} von ${aufgabe.kriterien.length} Kriterien erfüllt.` };
|
||||
}
|
||||
|
||||
export async function aufgabeAbnehmen(formular: FormData): Promise<void> {
|
||||
const ausbilder = await erfordereBenutzer();
|
||||
const azubiId = String(formular.get('azubiId') ?? '');
|
||||
const aufgabeId = String(formular.get('aufgabeId') ?? '');
|
||||
|
||||
erfordereBewertungsrecht(ausbilder, azubiId);
|
||||
if (!aufgabeGibtEs(aufgabeId)) return;
|
||||
|
||||
abnehmen(azubiId, aufgabeId, ausbilder.id);
|
||||
aktualisiere(aufgabeId);
|
||||
}
|
||||
|
||||
export async function aufgabeZurueckgeben(formular: FormData): Promise<void> {
|
||||
const ausbilder = await erfordereBenutzer();
|
||||
const azubiId = String(formular.get('azubiId') ?? '');
|
||||
const aufgabeId = String(formular.get('aufgabeId') ?? '');
|
||||
|
||||
erfordereBewertungsrecht(ausbilder, azubiId);
|
||||
if (!aufgabeGibtEs(aufgabeId)) return;
|
||||
|
||||
zurueckgeben(azubiId, aufgabeId);
|
||||
aktualisiere(aufgabeId);
|
||||
}
|
||||
|
||||
/** Wird von der Aufgabenseite gebraucht, um fremde Daten überhaupt lesen zu dürfen. */
|
||||
export async function pruefeLesezugriff(azubiId: string): Promise<void> {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
erfordereZugriffAufAzubi(benutzer, azubiId);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { staendeVon } from '@/lib/repos/aufgaben';
|
||||
import { alsZeitpunktText } from '@/lib/zeit';
|
||||
import { StatusMarke } from './StatusMarke';
|
||||
|
||||
export const metadata: Metadata = { title: 'Praxisaufgaben' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AufgabenListe() {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const alle = inhalte();
|
||||
|
||||
if (benutzer.rolle === 'ausbilder') {
|
||||
const meine = ausbildungenVonAusbilder(benutzer.id);
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Praxisaufgaben</h1>
|
||||
<p className="leise">
|
||||
{alle.aufgaben.length} Aufgaben · Stand deiner {meine.length === 1 ? 'Auszubildenden' : 'Auszubildenden'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{meine.length === 0 ? (
|
||||
<p className="hinweis hinweis-warnung">
|
||||
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
|
||||
</p>
|
||||
) : (
|
||||
meine.map(({ azubi }) => {
|
||||
const staende = staendeVon(azubi.id);
|
||||
const wartend = alle.aufgaben.filter((a) => staende.get(a.id)?.status === 'zur_abnahme');
|
||||
|
||||
return (
|
||||
<section key={azubi.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
|
||||
{azubi.name}
|
||||
</h2>
|
||||
{wartend.length > 0 ? (
|
||||
<span className="marke-status" data-ton="warnung">
|
||||
● {wartend.length} {wartend.length === 1 ? 'Abnahme' : 'Abnahmen'} offen
|
||||
</span>
|
||||
) : (
|
||||
<span className="leise">nichts zur Abnahme</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="aufgabenliste">
|
||||
{alle.aufgaben.map((aufgabe) => {
|
||||
const stand = staende.get(aufgabe.id);
|
||||
return (
|
||||
<li key={aufgabe.id}>
|
||||
<Link href={`/aufgaben/${aufgabe.id}?azubi=${azubi.id}`} className="aufgabenzeile">
|
||||
<span className="aufgaben-nummer">{aufgabe.id}</span>
|
||||
<span className="aufgaben-titel">
|
||||
{aufgabe.titel}
|
||||
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
|
||||
Woche {aufgabe.woche} · {aufgabe.zeitrahmen}
|
||||
{stand?.abgegebenAm ? ` · abgegeben ${alsZeitpunktText(stand.abgegebenAm)}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<StatusMarke status={stand?.status ?? 'offen'} />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sicht des Azubis ---
|
||||
const staende = staendeVon(benutzer.id);
|
||||
const abgenommen = alle.aufgaben.filter((a) => staende.get(a.id)?.status === 'abgenommen').length;
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Praxisaufgaben</h1>
|
||||
<p className="leise">
|
||||
{abgenommen} von {alle.aufgaben.length} abgenommen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="aufgabenliste">
|
||||
{alle.aufgaben.map((aufgabe) => {
|
||||
const stand = staende.get(aufgabe.id);
|
||||
return (
|
||||
<li key={aufgabe.id}>
|
||||
<Link href={`/aufgaben/${aufgabe.id}`} className="aufgabenzeile">
|
||||
<span className="aufgaben-nummer">{aufgabe.id}</span>
|
||||
<span className="aufgaben-titel">
|
||||
{aufgabe.titel}
|
||||
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
|
||||
Woche {aufgabe.woche} · {aufgabe.zeitrahmen} · {aufgabe.kriterien.length} Kriterien
|
||||
</span>
|
||||
</span>
|
||||
<StatusMarke status={stand?.status ?? 'offen'} />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { erfordereBenutzer, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
|
||||
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
|
||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||
import { eigenerStand, rahmenplanabdeckung } from '@/lib/auswertung/sammeln';
|
||||
import { bilanz, nachGruppen, stufenText, type Abdeckungsstufe } from '@/lib/auswertung/abdeckung';
|
||||
import { verlauf } from '@/lib/kontrollen/bewertung';
|
||||
import { Druckknopf } from '@/komponenten/Druckknopf';
|
||||
|
||||
export const metadata: Metadata = { title: 'Auswertung' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const stufenTon: Record<Abdeckungsstufe, string> = {
|
||||
abgedeckt: 'gut',
|
||||
angefangen: 'warnung',
|
||||
offen: 'neutral',
|
||||
ohne_inhalt: 'fehler',
|
||||
};
|
||||
|
||||
const stufenZeichen: Record<Abdeckungsstufe, string> = {
|
||||
abgedeckt: '✓',
|
||||
angefangen: '◐',
|
||||
offen: '○',
|
||||
ohne_inhalt: '!',
|
||||
};
|
||||
|
||||
export default async function AuswertungsSeite({ searchParams }: { searchParams: Promise<{ azubi?: string }> }) {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const { azubi: azubiParameter } = await searchParams;
|
||||
|
||||
if (benutzer.rolle === 'azubi') {
|
||||
return <AzubiAuswertung azubiId={benutzer.id} name={benutzer.name} />;
|
||||
}
|
||||
|
||||
const meine = ausbildungenVonAusbilder(benutzer.id);
|
||||
if (meine.length === 0) {
|
||||
return (
|
||||
<div className="stapel">
|
||||
<h1>Auswertung</h1>
|
||||
<p className="hinweis hinweis-warnung">
|
||||
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const azubiId = azubiParameter ?? meine[0]!.azubi.id;
|
||||
erfordereSichtAufAzubi(benutzer, azubiId);
|
||||
const azubi = benutzerNachId(azubiId)!;
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Auswertung</h1>
|
||||
<p className="leise">{azubi.name}</p>
|
||||
</div>
|
||||
|
||||
{meine.length > 1 ? (
|
||||
<nav className="reihe nicht-drucken" aria-label="Azubi wählen">
|
||||
{meine.map(({ azubi: person }) => (
|
||||
<Link
|
||||
key={person.id}
|
||||
href={`/auswertung?azubi=${person.id}`}
|
||||
className={`knopf ${person.id === azubiId ? '' : 'knopf-zweit'}`}
|
||||
>
|
||||
{person.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<Rahmenplan azubiId={azubiId} />
|
||||
<Kontrollverlauf azubiId={azubiId} />
|
||||
<Lernfelder azubiId={azubiId} />
|
||||
|
||||
{/* Die Abdeckung ist die Ansicht, die man zum Gespräch mitnimmt – und zwar
|
||||
auf Papier, weil dort niemand scrollt. */}
|
||||
<div className="reihe nicht-drucken">
|
||||
<Druckknopf beschriftung="Auswertung drucken" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sicht des Azubis ------------------------------------------------------
|
||||
|
||||
function AzubiAuswertung({ azubiId, name }: { azubiId: string; name: string }) {
|
||||
const ausbildung = ausbildungNachAzubi(azubiId);
|
||||
const stand = eigenerStand(azubiId, ausbildung?.beginn ?? null);
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Dein Stand</h1>
|
||||
<p className="leise">{name}</p>
|
||||
</div>
|
||||
|
||||
<div className="raster">
|
||||
<Kachel
|
||||
titel="Handouts gelesen"
|
||||
wert={`${stand.handouts.erledigt} von ${stand.handouts.gesamt}`}
|
||||
anteil={anteil(stand.handouts.erledigt, stand.handouts.gesamt)}
|
||||
fuss={`${stand.handouts.gesamt - stand.handouts.erledigt} noch offen`}
|
||||
/>
|
||||
<Kachel
|
||||
titel="Merkkarten, die sitzen"
|
||||
wert={`${stand.karten.erledigt} von ${stand.karten.gesamt}`}
|
||||
anteil={anteil(stand.karten.erledigt, stand.karten.gesamt)}
|
||||
fuss={stand.karten.faellig > 0 ? `${stand.karten.faellig} heute fällig` : 'heute nichts fällig'}
|
||||
/>
|
||||
<Kachel
|
||||
titel="Praxisaufgaben abgenommen"
|
||||
wert={`${stand.aufgaben.erledigt} von ${stand.aufgaben.gesamt}`}
|
||||
anteil={anteil(stand.aufgaben.erledigt, stand.aufgaben.gesamt)}
|
||||
fuss={
|
||||
stand.aufgaben.zurAbnahme > 0
|
||||
? `${stand.aufgaben.zurAbnahme} wartet auf Abnahme`
|
||||
: `${stand.aufgaben.offen} noch offen`
|
||||
}
|
||||
/>
|
||||
<Kachel
|
||||
titel="Berichtsheft abgezeichnet"
|
||||
wert={`${stand.berichtsheft.abgezeichnet} von ${stand.berichtsheft.gesamt}`}
|
||||
anteil={anteil(stand.berichtsheft.abgezeichnet, stand.berichtsheft.gesamt)}
|
||||
fuss={
|
||||
stand.berichtsheft.fehlendeWochen > 0
|
||||
? `${stand.berichtsheft.fehlendeWochen} Wochen ohne Blatt`
|
||||
: stand.berichtsheft.eingereicht > 0
|
||||
? `${stand.berichtsheft.eingereicht} beim Ausbilder`
|
||||
: 'nichts offen'
|
||||
}
|
||||
ton={stand.berichtsheft.fehlendeWochen > 0 ? 'fehler' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{stand.berichtsheft.fehlendeWochen > 0 ? (
|
||||
<p className="hinweis hinweis-warnung">
|
||||
<strong>
|
||||
{stand.berichtsheft.fehlendeWochen}{' '}
|
||||
{stand.berichtsheft.fehlendeWochen === 1 ? 'Woche' : 'Wochen'} ohne Berichtsheftblatt.
|
||||
</strong>{' '}
|
||||
Ohne geführten Nachweis gibt es keine Zulassung zur Abschlussprüfung.{' '}
|
||||
<Link href="/berichtsheft">Jetzt nachtragen</Link>.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Kachel({
|
||||
titel,
|
||||
wert,
|
||||
anteil,
|
||||
fuss,
|
||||
ton,
|
||||
}: {
|
||||
titel: string;
|
||||
wert: string;
|
||||
anteil: number;
|
||||
fuss: string;
|
||||
ton?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="karte stapel-eng">
|
||||
<h2 style={{ fontSize: '0.9rem', marginBottom: 0, color: 'var(--text-leise)' }}>{titel}</h2>
|
||||
<strong style={{ fontSize: '1.5rem' }}>{wert}</strong>
|
||||
<div className="balken">
|
||||
<span style={{ width: `${anteil}%` }} />
|
||||
</div>
|
||||
<span className="leise" style={{ color: ton === 'fehler' ? 'var(--fehler)' : undefined }}>
|
||||
{fuss}
|
||||
</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Rahmenplanabdeckung ---------------------------------------------------
|
||||
|
||||
function Rahmenplan({ azubiId }: { azubiId: string }) {
|
||||
const { positionen, gruppennamen } = rahmenplanabdeckung(azubiId);
|
||||
const gruppen = nachGruppen(positionen);
|
||||
const gesamt = bilanz(positionen);
|
||||
|
||||
return (
|
||||
<section className="stapel" aria-labelledby="rahmenplan">
|
||||
<div>
|
||||
<h2 id="rahmenplan" style={{ marginBottom: '0.2rem' }}>
|
||||
Abdeckung des Ausbildungsrahmenplans
|
||||
</h2>
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Die Ansicht, die im Zweifel gegenüber der IHK zählt. Als <strong>vermittelt</strong> gilt eine
|
||||
Position, wenn es dazu eine abgenommene Praxisaufgabe oder einen abgeschlossenen
|
||||
Checklisten-Durchlauf gibt – oder mindestens zwei gelesene Handouts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="karte-grund stapel-eng">
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>
|
||||
{gesamt.abgedeckt} von {gesamt.gesamt} Positionen vermittelt
|
||||
</strong>
|
||||
<span className="leise">{gesamt.prozent} %</span>
|
||||
</div>
|
||||
<div className="balken" style={{ height: '14px' }}>
|
||||
<span style={{ width: `${gesamt.prozent}%` }} />
|
||||
</div>
|
||||
<div className="reihe" style={{ gap: '0.5rem' }}>
|
||||
<span className="marke-status" data-ton="gut">
|
||||
✓ {gesamt.abgedeckt} vermittelt
|
||||
</span>
|
||||
<span className="marke-status" data-ton="warnung">
|
||||
◐ {gesamt.angefangen} angefangen
|
||||
</span>
|
||||
<span className="marke-status">○ {gesamt.offen} geplant</span>
|
||||
{gesamt.ohneInhalt > 0 ? (
|
||||
<span className="marke-status" data-ton="fehler">
|
||||
! {gesamt.ohneInhalt} ohne Inhalt
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gesamt.ohneInhalt > 0 ? (
|
||||
<p className="hinweis hinweis-warnung">
|
||||
<strong>
|
||||
Zu {gesamt.ohneInhalt} {gesamt.ohneInhalt === 1 ? 'Position' : 'Positionen'} gibt es im Lehrgang
|
||||
überhaupt keinen Inhalt.
|
||||
</strong>{' '}
|
||||
Das ist keine Lücke beim Azubi, sondern eine im Lehrgang – dort hilft kein Lernen, sondern nur ein
|
||||
neues Handout oder eine neue Aufgabe unter <code>content/</code>.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{gruppen.map((gruppe) => (
|
||||
<details key={gruppe.gruppe} className="abdeckungsgruppe karte" open={gruppe.bilanz.ohneInhalt > 0}>
|
||||
<summary>
|
||||
<strong>{gruppennamen[gruppe.gruppe] ?? gruppe.gruppe}</strong>
|
||||
<span className="leise">
|
||||
{' '}
|
||||
– {gruppe.bilanz.abgedeckt} von {gruppe.bilanz.gesamt} vermittelt
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<ul className="abdeckungsliste">
|
||||
{gruppe.positionen.map((position) => (
|
||||
<li key={position.id} data-stufe={position.stufe}>
|
||||
<span className="abdeckung-kuerzel">{position.id}</span>
|
||||
|
||||
<span className="abdeckung-titel">
|
||||
{position.titel}
|
||||
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
|
||||
{position.verordnung}
|
||||
{position.geplant > 0
|
||||
? ` · ${position.nachweise.filter((n) => n.erledigt).length} von ${position.geplant} Nachweisen`
|
||||
: ''}
|
||||
</span>
|
||||
|
||||
{position.nachweise.length > 0 ? (
|
||||
<span className="nachweisliste">
|
||||
{position.nachweise.map((nachweis) => (
|
||||
<span
|
||||
key={`${nachweis.art}-${nachweis.id}`}
|
||||
className="nachweis"
|
||||
data-erledigt={nachweis.erledigt ? 'ja' : 'nein'}
|
||||
title={`${nachweis.titel}${nachweis.erledigtAm ? ` – ${nachweis.erledigtAm}` : ''}`}
|
||||
>
|
||||
{nachweis.erledigt ? '✓' : '○'} {nachweis.id}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
<span className="marke-status" data-ton={stufenTon[position.stufe]}>
|
||||
{stufenZeichen[position.stufe]} {stufenText[position.stufe]}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Lernfelder ------------------------------------------------------------
|
||||
|
||||
function Lernfelder({ azubiId }: { azubiId: string }) {
|
||||
const { lernfelder } = rahmenplanabdeckung(azubiId);
|
||||
const jahre = [1, 2, 3];
|
||||
|
||||
return (
|
||||
<section className="stapel" aria-labelledby="lernfelder">
|
||||
<div>
|
||||
<h2 id="lernfelder" style={{ marginBottom: '0.2rem' }}>
|
||||
Lernfelder der Berufsschule
|
||||
</h2>
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Nach derselben Regel bewertet. Der Unterricht selbst findet in der Schule statt – hier steht, was im
|
||||
Betrieb dazu angekommen ist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{jahre.map((jahr) => {
|
||||
const eigene = lernfelder.filter((l) => l.jahr === jahr);
|
||||
if (eigene.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={jahr} className="karte stapel-eng">
|
||||
<h3 style={{ fontSize: '0.95rem', marginBottom: 0 }}>{jahr}. Ausbildungsjahr</h3>
|
||||
<ul className="abdeckungsliste">
|
||||
{eigene.map((lernfeld) => (
|
||||
<li key={lernfeld.id} data-stufe={lernfeld.stufe}>
|
||||
<span className="abdeckung-kuerzel">{lernfeld.id}</span>
|
||||
<span className="abdeckung-titel">
|
||||
{lernfeld.titel}
|
||||
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
|
||||
{lernfeld.stunden} Stunden
|
||||
</span>
|
||||
</span>
|
||||
<span className="marke-status" data-ton={stufenTon[lernfeld.stufe]}>
|
||||
{stufenZeichen[lernfeld.stufe]} {stufenText[lernfeld.stufe]}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Ergebnisverlauf -------------------------------------------------------
|
||||
|
||||
function Kontrollverlauf({ azubiId }: { azubiId: string }) {
|
||||
const ausbildung = ausbildungNachAzubi(azubiId);
|
||||
const stand = eigenerStand(azubiId, ausbildung?.beginn ?? null);
|
||||
return <ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} />;
|
||||
}
|
||||
|
||||
function ErgebnisTabelle({
|
||||
ergebnisse,
|
||||
}: {
|
||||
ergebnisse: {
|
||||
id: string;
|
||||
titel: string;
|
||||
woche: number;
|
||||
prozent: number;
|
||||
note: number;
|
||||
notentext: string;
|
||||
erreicht: number;
|
||||
moeglich: number;
|
||||
korrigiertAm: string;
|
||||
}[];
|
||||
}) {
|
||||
const stand = verlauf(ergebnisse);
|
||||
|
||||
return (
|
||||
<section className="stapel" aria-labelledby="verlauf">
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id="verlauf" style={{ margin: 0 }}>
|
||||
Lernzielkontrollen
|
||||
</h2>
|
||||
{stand.anzahl > 0 ? (
|
||||
<span className="leise">
|
||||
Schnitt {stand.schnittProzent} % · Note {stand.schnittNote.toString().replace('.', ',')} · beste{' '}
|
||||
{stand.beste} %
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{ergebnisse.length === 0 ? (
|
||||
<p className="karte" style={{ marginBottom: 0 }}>
|
||||
Noch keine korrigierte Lernzielkontrolle.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="verlaufsliste">
|
||||
{ergebnisse.map((ergebnis) => (
|
||||
<li key={ergebnis.id}>
|
||||
<span className="kontroll-woche">Woche {ergebnis.woche}</span>
|
||||
<span className="verlauf-titel">
|
||||
{ergebnis.titel}
|
||||
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
|
||||
{zahl(ergebnis.erreicht)} von {zahl(ergebnis.moeglich)} Punkten · korrigiert{' '}
|
||||
{ergebnis.korrigiertAm}
|
||||
</span>
|
||||
</span>
|
||||
<span className="verlauf-balken">
|
||||
<span className="balken">
|
||||
<span style={{ width: `${ergebnis.prozent}%` }} />
|
||||
</span>
|
||||
<span className="leise">{ergebnis.prozent} %</span>
|
||||
</span>
|
||||
<span className={`note-anzeige note-klein note-${ergebnis.note}`} title={ergebnis.notentext}>
|
||||
{ergebnis.note}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function anteil(erledigt: number, gesamt: number): number {
|
||||
if (gesamt <= 0) return 0;
|
||||
return Math.round((erledigt / gesamt) * 100);
|
||||
}
|
||||
|
||||
function zahl(wert: number): string {
|
||||
return wert.toString().replace('.', ',');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { BerichtStatus } from '@/lib/db/schema';
|
||||
|
||||
/** Zustand eines Wochenblatts – nie nur über die Farbe, immer mit Zeichen. */
|
||||
export const berichtsStatusText: Record<BerichtStatus, { text: string; ton: string; zeichen: string }> = {
|
||||
entwurf: { text: 'Entwurf', ton: 'neutral', zeichen: '○' },
|
||||
eingereicht: { text: 'eingereicht', ton: 'warnung', zeichen: '▲' },
|
||||
zurueckgegeben: { text: 'zurückgegeben', ton: 'fehler', zeichen: '↩' },
|
||||
abgezeichnet: { text: 'abgezeichnet', ton: 'gut', zeichen: '✓' },
|
||||
};
|
||||
|
||||
export function BerichtsMarke({ status }: { status: BerichtStatus }) {
|
||||
const marke = berichtsStatusText[status];
|
||||
return (
|
||||
<span className="marke-status" data-ton={marke.ton}>
|
||||
{marke.zeichen} {marke.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
|
||||
import { blattEinreichen, blattSpeichern, type Rueckmeldung } from './aktionen';
|
||||
import { pruefeWochenblatt, summeStunden, type Wochenblatt } from '@/lib/berichtsheft/pruefung';
|
||||
import type { Vorschlag } from '@/lib/berichtsheft/vorschlaege';
|
||||
|
||||
/**
|
||||
* Das Wochenblatt zum Ausfüllen.
|
||||
*
|
||||
* Die Prüfung läuft im Browser mit, während getippt wird – dieselbe Funktion,
|
||||
* die auch serverseitig verwendet wird. Sie warnt, sie sperrt nicht: Der Knopf
|
||||
* zum Einreichen bleibt immer bedienbar.
|
||||
*/
|
||||
export function Blattformular({
|
||||
berichtId,
|
||||
anfangsblatt,
|
||||
vorschlaege,
|
||||
status,
|
||||
}: {
|
||||
berichtId: string;
|
||||
anfangsblatt: Wochenblatt;
|
||||
vorschlaege: Vorschlag[];
|
||||
status: string;
|
||||
}) {
|
||||
const [blatt, setzeBlatt] = useState<Wochenblatt>(() => ({
|
||||
betrieb: anfangsblatt.betrieb.length > 0 ? anfangsblatt.betrieb : [{ text: '', stunden: 0 }],
|
||||
schule: anfangsblatt.schule.length > 0 ? anfangsblatt.schule : [{ lernfeld: '', themen: '', stunden: 0 }],
|
||||
sonstiges: anfangsblatt.sonstiges,
|
||||
}));
|
||||
const [uebernommen, setzeUebernommen] = useState<string[]>([]);
|
||||
|
||||
const [speicherZustand, speichern] = useActionState<Rueckmeldung, FormData>(blattSpeichern, {});
|
||||
const [einreichZustand, einreichen] = useActionState<Rueckmeldung, FormData>(blattEinreichen, {});
|
||||
|
||||
const gesperrt = status === 'abgezeichnet';
|
||||
const warnungen = pruefeWochenblatt(blatt);
|
||||
const ernste = warnungen.filter((w) => w.schwere === 'warnung');
|
||||
|
||||
function betriebAendern(stelle: number, teil: Partial<{ text: string; stunden: number }>) {
|
||||
setzeBlatt((v) => ({
|
||||
...v,
|
||||
betrieb: v.betrieb.map((e, i) => (i === stelle ? { ...e, ...teil } : e)),
|
||||
}));
|
||||
}
|
||||
|
||||
function schuleAendern(stelle: number, teil: Partial<{ lernfeld: string; themen: string; stunden: number }>) {
|
||||
setzeBlatt((v) => ({
|
||||
...v,
|
||||
schule: v.schule.map((e, i) => (i === stelle ? { ...e, ...teil } : e)),
|
||||
}));
|
||||
}
|
||||
|
||||
function uebernehmen(vorschlag: Vorschlag) {
|
||||
setzeUebernommen((v) => [...v, vorschlag.id]);
|
||||
|
||||
if (vorschlag.ziel === 'schule') {
|
||||
setzeBlatt((v) => {
|
||||
const leer = v.schule.findIndex((e) => e.lernfeld === '' && e.themen === '');
|
||||
const neu = { lernfeld: vorschlag.herkunft, themen: vorschlag.text, stunden: vorschlag.stunden };
|
||||
return {
|
||||
...v,
|
||||
schule: leer >= 0 ? v.schule.map((e, i) => (i === leer ? neu : e)) : [...v.schule, neu],
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setzeBlatt((v) => {
|
||||
const leer = v.betrieb.findIndex((e) => e.text.trim() === '');
|
||||
const neu = { text: vorschlag.text, stunden: vorschlag.stunden };
|
||||
return {
|
||||
...v,
|
||||
betrieb: leer >= 0 ? v.betrieb.map((e, i) => (i === leer ? neu : e)) : [...v.betrieb, neu],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const offeneVorschlaege = vorschlaege.filter((v) => !uebernommen.includes(v.id));
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
{!gesperrt && offeneVorschlaege.length > 0 ? (
|
||||
<section className="karte stapel-eng nicht-drucken" aria-labelledby="vorschlaege">
|
||||
<div>
|
||||
<h2 id="vorschlaege" style={{ marginBottom: '0.15rem', fontSize: '1rem' }}>
|
||||
Vorschläge aus dieser Woche
|
||||
</h2>
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Übernehmen und dann <strong>in eigene Worte bringen</strong>. Ein wörtlich übernommener Satz ist kein
|
||||
Ausbildungsnachweis, und die Stundenangabe ist nur ein Anhaltspunkt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="vorschlagsliste">
|
||||
{offeneVorschlaege.map((vorschlag) => (
|
||||
<li key={vorschlag.id}>
|
||||
<button type="button" className="vorschlag" onClick={() => uebernehmen(vorschlag)}>
|
||||
<span className="vorschlag-quelle" data-quelle={vorschlag.quelle}>
|
||||
{beschriftung[vorschlag.quelle]}
|
||||
</span>
|
||||
<span className="vorschlag-text">{vorschlag.text}</span>
|
||||
<span className="leise">
|
||||
{vorschlag.herkunft}
|
||||
{vorschlag.stunden > 0 ? ` · ${zahlText(vorschlag.stunden)} h` : ''}
|
||||
</span>
|
||||
<span className="vorschlag-plus" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<form action={speichern} className="stapel" id="blattformular">
|
||||
<input type="hidden" name="berichtId" value={berichtId} />
|
||||
|
||||
{/* --- Betriebliche Tätigkeiten --- */}
|
||||
<section className="stapel-eng" aria-labelledby="betrieb">
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id="betrieb" style={{ margin: 0 }}>
|
||||
Betriebliche Tätigkeiten und Unterweisungen
|
||||
</h2>
|
||||
<span className="leise">{zahlText(summeStunden({ ...blatt, schule: [] }))} h</span>
|
||||
</div>
|
||||
|
||||
{blatt.betrieb.map((eintrag, stelle) => (
|
||||
<div key={stelle} className="blattzeile">
|
||||
<div className="blattzeile-text">
|
||||
<label htmlFor={`betrieb-${stelle}`} className="nur-lesehilfe">
|
||||
Tätigkeit {stelle + 1}
|
||||
</label>
|
||||
<textarea
|
||||
id={`betrieb-${stelle}`}
|
||||
name="betriebText"
|
||||
rows={2}
|
||||
maxLength={600}
|
||||
value={eintrag.text}
|
||||
disabled={gesperrt}
|
||||
onChange={(e) => betriebAendern(stelle, { text: e.target.value })}
|
||||
placeholder="Was hast du gemacht? Gerät oder Dienst nennen, Tätigkeit, Ergebnis."
|
||||
aria-invalid={warnungen.some((w) => w.feld === `betrieb.${stelle}` && w.schwere === 'warnung')}
|
||||
/>
|
||||
<Feldwarnungen warnungen={warnungen} feld={`betrieb.${stelle}`} />
|
||||
</div>
|
||||
|
||||
<div className="blattzeile-stunden">
|
||||
<label htmlFor={`betrieb-std-${stelle}`}>Std.</label>
|
||||
<input
|
||||
id={`betrieb-std-${stelle}`}
|
||||
name="betriebStunden"
|
||||
type="number"
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.25}
|
||||
value={eintrag.stunden || ''}
|
||||
disabled={gesperrt}
|
||||
onChange={(e) => betriebAendern(stelle, { stunden: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!gesperrt ? (
|
||||
<button
|
||||
type="button"
|
||||
className="knopf knopf-still zeile-weg"
|
||||
onClick={() => setzeBlatt((v) => ({ ...v, betrieb: v.betrieb.filter((_, i) => i !== stelle) }))}
|
||||
title="Zeile entfernen"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
<span className="nur-lesehilfe">Tätigkeit {stelle + 1} entfernen</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!gesperrt ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="knopf knopf-zweit"
|
||||
onClick={() => setzeBlatt((v) => ({ ...v, betrieb: [...v.betrieb, { text: '', stunden: 0 }] }))}
|
||||
>
|
||||
Zeile hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* --- Berufsschule --- */}
|
||||
<section className="stapel-eng" aria-labelledby="schule">
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id="schule" style={{ margin: 0 }}>
|
||||
Berufsschule
|
||||
</h2>
|
||||
<span className="leise">{zahlText(summeStunden({ ...blatt, betrieb: [] }))} h</span>
|
||||
</div>
|
||||
|
||||
{blatt.schule.map((eintrag, stelle) => (
|
||||
<div key={stelle} className="blattzeile">
|
||||
<div className="blattzeile-lernfeld">
|
||||
<label htmlFor={`lf-${stelle}`}>Lernfeld</label>
|
||||
<input
|
||||
id={`lf-${stelle}`}
|
||||
name="schuleLernfeld"
|
||||
type="text"
|
||||
maxLength={10}
|
||||
value={eintrag.lernfeld}
|
||||
disabled={gesperrt}
|
||||
onChange={(e) => schuleAendern(stelle, { lernfeld: e.target.value })}
|
||||
placeholder="LF3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="blattzeile-text">
|
||||
<label htmlFor={`themen-${stelle}`} className="nur-lesehilfe">
|
||||
Themen
|
||||
</label>
|
||||
<textarea
|
||||
id={`themen-${stelle}`}
|
||||
name="schuleThemen"
|
||||
rows={2}
|
||||
maxLength={600}
|
||||
value={eintrag.themen}
|
||||
disabled={gesperrt}
|
||||
onChange={(e) => schuleAendern(stelle, { themen: e.target.value })}
|
||||
placeholder="Themen des Unterrichts"
|
||||
/>
|
||||
<Feldwarnungen warnungen={warnungen} feld={`schule.${stelle}`} />
|
||||
</div>
|
||||
|
||||
<div className="blattzeile-stunden">
|
||||
<label htmlFor={`schule-std-${stelle}`}>Std.</label>
|
||||
<input
|
||||
id={`schule-std-${stelle}`}
|
||||
name="schuleStunden"
|
||||
type="number"
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.25}
|
||||
value={eintrag.stunden || ''}
|
||||
disabled={gesperrt}
|
||||
onChange={(e) => schuleAendern(stelle, { stunden: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!gesperrt ? (
|
||||
<button
|
||||
type="button"
|
||||
className="knopf knopf-still zeile-weg"
|
||||
onClick={() => setzeBlatt((v) => ({ ...v, schule: v.schule.filter((_, i) => i !== stelle) }))}
|
||||
title="Zeile entfernen"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
<span className="nur-lesehilfe">Schulzeile {stelle + 1} entfernen</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!gesperrt ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="knopf knopf-zweit"
|
||||
onClick={() =>
|
||||
setzeBlatt((v) => ({ ...v, schule: [...v.schule, { lernfeld: '', themen: '', stunden: 0 }] }))
|
||||
}
|
||||
>
|
||||
Zeile hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* --- Sonstiges --- */}
|
||||
<section className="stapel-eng" aria-labelledby="sonstiges">
|
||||
<h2 id="sonstiges" style={{ margin: 0 }}>
|
||||
Sonstiges
|
||||
</h2>
|
||||
<label htmlFor="sonstiges-feld" className="nur-lesehilfe">
|
||||
Sonstiges
|
||||
</label>
|
||||
<textarea
|
||||
id="sonstiges-feld"
|
||||
name="sonstiges"
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
value={blatt.sonstiges}
|
||||
disabled={gesperrt}
|
||||
onChange={(e) => setzeBlatt((v) => ({ ...v, sonstiges: e.target.value }))}
|
||||
placeholder="Urlaub, Krankheit, Feiertage, Betriebsversammlung – alles, was keine Tätigkeit ist."
|
||||
/>
|
||||
<Feldwarnungen warnungen={warnungen} feld="sonstiges" />
|
||||
</section>
|
||||
|
||||
{/* --- Summe und Prüfung --- */}
|
||||
<section className="karte stapel-eng" aria-labelledby="pruefung">
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id="pruefung" style={{ margin: 0, fontSize: '1rem' }}>
|
||||
Prüfung
|
||||
</h2>
|
||||
<strong>{zahlText(summeStunden(blatt))} Stunden gesamt</strong>
|
||||
</div>
|
||||
|
||||
{warnungen.length === 0 ? (
|
||||
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
|
||||
✓ Nichts zu beanstanden.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="warnliste">
|
||||
{warnungen.map((warnung, stelle) => (
|
||||
<li key={stelle} data-schwere={warnung.schwere}>
|
||||
<span className="warnzeichen" aria-hidden="true">
|
||||
{warnung.schwere === 'warnung' ? '!' : 'i'}
|
||||
</span>
|
||||
<span>
|
||||
{warnung.text}
|
||||
{warnung.auszug ? <em> – „{warnung.auszug}“</em> : null}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
Das sind Hinweise, keine Sperre. Du kannst jederzeit einreichen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Meldung zustand={speicherZustand} />
|
||||
|
||||
{!gesperrt ? (
|
||||
<div className="reihe nicht-drucken">
|
||||
<Absenden className="knopf knopf-zweit">Speichern</Absenden>
|
||||
<button type="submit" className="knopf knopf-akzent" formAction={einreichen}>
|
||||
{ernste.length > 0 ? `Trotz ${ernste.length} Warnungen einreichen` : 'Einreichen'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Meldung zustand={einreichZustand} />
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Feldwarnungen({
|
||||
warnungen,
|
||||
feld,
|
||||
}: {
|
||||
warnungen: ReturnType<typeof pruefeWochenblatt>;
|
||||
feld: string;
|
||||
}) {
|
||||
const eigene = warnungen.filter((w) => w.feld === feld);
|
||||
if (eigene.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul className="feldwarnung">
|
||||
{eigene.map((warnung, stelle) => (
|
||||
<li key={stelle} data-schwere={warnung.schwere}>
|
||||
{warnung.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const beschriftung: Record<string, string> = {
|
||||
tagesnotiz: 'Tagesnotiz',
|
||||
aufgabe: 'Aufgabe',
|
||||
checkliste: 'Checkliste',
|
||||
handout: 'Lerninhalt',
|
||||
};
|
||||
|
||||
function zahlText(wert: number): string {
|
||||
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { gegenueberstellungen, regeln, verbgruppen } from '@/lib/berichtsheft/formulierung';
|
||||
|
||||
/**
|
||||
* Formulierungshilfe: Tätigkeitsverben und die Gegenüberstellung
|
||||
* „so besser nicht / so ist es richtig".
|
||||
*
|
||||
* Eingeklappt, weil sie beim zweiten Wochenblatt nur noch stört – aber sie
|
||||
* bleibt griffbereit, statt in einer Anleitung zu verschwinden.
|
||||
*/
|
||||
export function Formulierungshilfe() {
|
||||
return (
|
||||
<details className="karte formulierungshilfe nicht-drucken">
|
||||
<summary>
|
||||
<strong>Formulierungshilfe</strong>
|
||||
<span className="leise"> – Tätigkeitsverben und Beispiele</span>
|
||||
</summary>
|
||||
|
||||
<div className="stapel" style={{ marginTop: '0.9rem' }}>
|
||||
<section aria-labelledby="regeln">
|
||||
<h3 id="regeln" style={{ fontSize: '0.95rem' }}>
|
||||
Sechs Regeln
|
||||
</h3>
|
||||
<ol className="stapel-eng" style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
{regeln.map((regel, stelle) => (
|
||||
<li key={stelle}>{regel}</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="verben">
|
||||
<h3 id="verben" style={{ fontSize: '0.95rem' }}>
|
||||
Tätigkeitsverben
|
||||
</h3>
|
||||
<div className="verbgruppen">
|
||||
{verbgruppen.map((gruppe) => (
|
||||
<div key={gruppe.titel}>
|
||||
<span className="wochentag-marke">{gruppe.titel}</span>
|
||||
<p style={{ margin: 0 }}>{gruppe.verben.join(' · ')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="beispiele">
|
||||
<h3 id="beispiele" style={{ fontSize: '0.95rem' }}>
|
||||
So besser nicht – so ist es richtig
|
||||
</h3>
|
||||
|
||||
<ul className="gegenueberstellung">
|
||||
{gegenueberstellungen.map((beispiel, stelle) => (
|
||||
<li key={stelle}>
|
||||
<p className="schlecht">
|
||||
<span className="marke-status" data-ton="fehler">
|
||||
✗ so nicht
|
||||
</span>{' '}
|
||||
{beispiel.schlecht}
|
||||
</p>
|
||||
<p className="gut">
|
||||
<span className="marke-status" data-ton="gut">
|
||||
✓ besser
|
||||
</span>{' '}
|
||||
{beispiel.gut}
|
||||
</p>
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
{beispiel.warum}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
|
||||
import { tagesnotizSpeichern, type Rueckmeldung } from './aktionen';
|
||||
|
||||
export type Tagesfeld = {
|
||||
datum: string;
|
||||
wochentag: string;
|
||||
datumText: string;
|
||||
text: string;
|
||||
stunden: number;
|
||||
istHeute: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Die täglichen Stichworte.
|
||||
*
|
||||
* Der Zweck: zwei Minuten am Tag statt einer Stunde Rekonstruktion am Freitag.
|
||||
* Deshalb je Tag ein eigenes kleines Formular, das für sich allein absendet –
|
||||
* ein großes Formular für die ganze Woche würde bei jedem Speichern alles
|
||||
* neu schreiben und wäre am Handy unbrauchbar.
|
||||
*/
|
||||
export function Tagesnotizen({ tage }: { tage: Tagesfeld[] }) {
|
||||
return (
|
||||
<div className="tagesnotizen">
|
||||
{tage.map((tag) => (
|
||||
<Tagesnotiz key={tag.datum} tag={tag} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tagesnotiz({ tag }: { tag: Tagesfeld }) {
|
||||
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(tagesnotizSpeichern, {});
|
||||
|
||||
return (
|
||||
<form action={aktion} className={`tagesnotiz${tag.istHeute ? ' tagesnotiz-heute' : ''}`}>
|
||||
<input type="hidden" name="datum" value={tag.datum} />
|
||||
|
||||
<div className="tagesnotiz-kopf">
|
||||
<strong>{tag.wochentag}</strong>
|
||||
<span className="leise">{tag.datumText}</span>
|
||||
{tag.istHeute ? (
|
||||
<span className="marke-status" data-ton="warnung">
|
||||
● heute
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<label htmlFor={`notiz-${tag.datum}`} className="nur-lesehilfe">
|
||||
Stichworte für {tag.wochentag}
|
||||
</label>
|
||||
<textarea
|
||||
id={`notiz-${tag.datum}`}
|
||||
name="text"
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
defaultValue={tag.text}
|
||||
placeholder="Stichworte genügen – woran hast du heute gearbeitet?"
|
||||
/>
|
||||
|
||||
<div className="tagesnotiz-fuss">
|
||||
<label htmlFor={`std-${tag.datum}`}>Stunden</label>
|
||||
<input
|
||||
id={`std-${tag.datum}`}
|
||||
name="stunden"
|
||||
type="number"
|
||||
min={0}
|
||||
max={24}
|
||||
step={0.25}
|
||||
defaultValue={tag.stunden || ''}
|
||||
/>
|
||||
<Absenden className="knopf knopf-still">Merken</Absenden>
|
||||
</div>
|
||||
|
||||
<Meldung zustand={zustand} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
|
||||
import { blattAbzeichnen, blattZurueckgeben, type Rueckmeldung } from '../aktionen';
|
||||
|
||||
/**
|
||||
* Abzeichnen oder zurückgeben.
|
||||
*
|
||||
* Zurückgeben verlangt eine Anmerkung – ohne sie weiß der Azubi nicht, was er
|
||||
* ändern soll, und das Blatt pendelt hin und her.
|
||||
*/
|
||||
export function Abzeichnen({
|
||||
berichtId,
|
||||
anmerkung,
|
||||
status,
|
||||
}: {
|
||||
berichtId: string;
|
||||
anmerkung: string;
|
||||
status: string;
|
||||
}) {
|
||||
const [abzeichenZustand, abzeichnen] = useActionState<Rueckmeldung, FormData>(blattAbzeichnen, {});
|
||||
const [rueckgabeZustand, zurueckgeben] = useActionState<Rueckmeldung, FormData>(blattZurueckgeben, {});
|
||||
|
||||
if (status === 'abgezeichnet') return null;
|
||||
|
||||
return (
|
||||
<form action={abzeichnen} className="stapel-eng">
|
||||
<input type="hidden" name="berichtId" value={berichtId} />
|
||||
|
||||
<Meldung zustand={abzeichenZustand} />
|
||||
<Meldung zustand={rueckgabeZustand} />
|
||||
|
||||
<label htmlFor="anmerkung">Anmerkung an den Azubi</label>
|
||||
<textarea
|
||||
id="anmerkung"
|
||||
name="anmerkung"
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
defaultValue={anmerkung}
|
||||
placeholder="Beim Abzeichnen freiwillig, beim Zurückgeben nötig: Was soll er ändern?"
|
||||
/>
|
||||
|
||||
<div className="reihe">
|
||||
<Absenden className="knopf knopf-akzent">Abzeichnen</Absenden>
|
||||
<button type="submit" className="knopf knopf-zweit" formAction={zurueckgeben}>
|
||||
Zur Nacharbeit zurückgeben
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
Abzeichnen sperrt das Blatt endgültig – der Azubi kann es danach nicht mehr ändern.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { alsWochenblatt, notizenIm } from '@/lib/repos/berichtsheft';
|
||||
import { staendeVon as aufgabenStaende } from '@/lib/repos/aufgaben';
|
||||
import { laeufeVon } from '@/lib/repos/checklisten';
|
||||
import { gelesenSet } from '@/lib/repos/fortschritt';
|
||||
import { planVon } from '@/lib/repos/wochenplanung';
|
||||
import { baueVorschlaege, type Rohdaten } from '@/lib/berichtsheft/vorschlaege';
|
||||
import { pruefeWochenblatt, summeBetrieb, summeSchule, summeStunden } from '@/lib/berichtsheft/pruefung';
|
||||
import { alsDatumText, alsZeitpunktText, kalenderwocheText } from '@/lib/zeit';
|
||||
import { Blattformular } from '../Blattformular';
|
||||
import { Formulierungshilfe } from '../Formulierungshilfe';
|
||||
import { BerichtsMarke } from '../BerichtsMarke';
|
||||
import { Abzeichnen } from './Abzeichnen';
|
||||
import { blattZurueckziehen, blattZumLesen } from '../aktionen';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ berichtId: string }> }): Promise<Metadata> {
|
||||
const { berichtId } = await params;
|
||||
const gelesen = await blattZumLesen(berichtId);
|
||||
return { title: gelesen ? `Wochenblatt Nr. ${gelesen.bericht.nr}` : 'Wochenblatt' };
|
||||
}
|
||||
|
||||
export default async function BlattSeite({ params }: { params: Promise<{ berichtId: string }> }) {
|
||||
const { berichtId } = await params;
|
||||
|
||||
// Rechteprüfung steckt hier drin: kein Zugriff bedeutet null bedeutet 404.
|
||||
const gelesen = await blattZumLesen(berichtId);
|
||||
if (!gelesen) notFound();
|
||||
|
||||
const { benutzer, bericht } = gelesen;
|
||||
const eigenes = bericht.benutzerId === benutzer.id;
|
||||
const azubi = eigenes ? benutzer : benutzerNachId(bericht.benutzerId);
|
||||
if (!azubi) notFound();
|
||||
|
||||
const blatt = alsWochenblatt(bericht);
|
||||
const warnungen = pruefeWochenblatt(blatt);
|
||||
const vorschlaege = eigenes ? sammleVorschlaege(azubi.id, bericht.von, bericht.bis) : [];
|
||||
|
||||
return (
|
||||
<article className="stapel">
|
||||
<nav className="leise nicht-drucken" aria-label="Wo du bist">
|
||||
<Link href="/berichtsheft">Berichtsheft</Link>
|
||||
{!eigenes ? ` › ${azubi.name}` : ''}
|
||||
</nav>
|
||||
|
||||
<header className="stapel-eng">
|
||||
<div className="reihe" style={{ gap: '0.6rem' }}>
|
||||
<span className="blatt-nr blatt-nr-gross">Nr. {bericht.nr}</span>
|
||||
<BerichtsMarke status={bericht.status} />
|
||||
<span className="leise">{kalenderwocheText(bericht.von)}</span>
|
||||
</div>
|
||||
|
||||
<h1 style={{ marginBottom: '0.2rem' }}>
|
||||
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
|
||||
</h1>
|
||||
<p className="leise">
|
||||
{azubi.name}
|
||||
{bericht.eingereichtAm ? ` · eingereicht ${alsZeitpunktText(bericht.eingereichtAm)}` : ''}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{bericht.status === 'abgezeichnet' ? (
|
||||
<p className="hinweis hinweis-gut">
|
||||
✓ Abgezeichnet am {alsZeitpunktText(bericht.abgezeichnetAm)}
|
||||
{bericht.abgezeichnetVon ? ` von ${benutzerNachId(bericht.abgezeichnetVon)?.name ?? 'dem Ausbilder'}` : ''}.
|
||||
Das Blatt ist gesperrt.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{bericht.status === 'zurueckgegeben' && bericht.anmerkung ? (
|
||||
<div className="hinweis hinweis-warnung">
|
||||
<strong>Zur Nacharbeit zurückgegeben:</strong>
|
||||
<p className="notiz-anzeige" style={{ marginTop: '0.4rem' }}>
|
||||
{bericht.anmerkung}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{bericht.status === 'abgezeichnet' && bericht.anmerkung ? (
|
||||
<div className="karte">
|
||||
<strong>Anmerkung des Ausbilders</strong>
|
||||
<p className="notiz-anzeige" style={{ marginTop: '0.4rem' }}>
|
||||
{bericht.anmerkung}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{eigenes ? (
|
||||
<Blattformular
|
||||
berichtId={bericht.id}
|
||||
anfangsblatt={blatt}
|
||||
vorschlaege={vorschlaege}
|
||||
status={bericht.status}
|
||||
/>
|
||||
) : (
|
||||
<AnsichtFuerAusbilder blatt={blatt} warnungen={warnungen} />
|
||||
)}
|
||||
|
||||
{/* --- Werkzeuge --- */}
|
||||
|
||||
<div className="reihe nicht-drucken">
|
||||
<a href={`/berichtsheft/${bericht.id}/pdf`} className="knopf knopf-zweit" target="_blank" rel="noreferrer">
|
||||
Dieses Blatt als PDF
|
||||
</a>
|
||||
|
||||
{eigenes && bericht.status === 'eingereicht' ? (
|
||||
<form action={blattZurueckziehen}>
|
||||
<input type="hidden" name="berichtId" value={bericht.id} />
|
||||
<button type="submit" className="knopf knopf-still">
|
||||
Einreichung zurückziehen
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!eigenes ? (
|
||||
<section className="karte-grund stapel nicht-drucken" aria-labelledby="abzeichnen">
|
||||
<h2 id="abzeichnen" style={{ marginBottom: 0 }}>
|
||||
Abzeichnen
|
||||
</h2>
|
||||
<Abzeichnen berichtId={bericht.id} anmerkung={bericht.anmerkung ?? ''} status={bericht.status} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{eigenes ? <Formulierungshilfe /> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** Die Nur-Lese-Ansicht für den Ausbilder – dieselben Daten, ohne Formular. */
|
||||
function AnsichtFuerAusbilder({
|
||||
blatt,
|
||||
warnungen,
|
||||
}: {
|
||||
blatt: ReturnType<typeof alsWochenblatt>;
|
||||
warnungen: ReturnType<typeof pruefeWochenblatt>;
|
||||
}) {
|
||||
return (
|
||||
<div className="stapel">
|
||||
<section className="karte stapel-eng" aria-labelledby="betrieb-lesen">
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id="betrieb-lesen" style={{ margin: 0, fontSize: '1rem' }}>
|
||||
Betriebliche Tätigkeiten und Unterweisungen
|
||||
</h2>
|
||||
<span className="leise">{zahlText(summeBetrieb(blatt))} h</span>
|
||||
</div>
|
||||
|
||||
{blatt.betrieb.length === 0 ? (
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
Keine Eintragung.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="leseliste">
|
||||
{blatt.betrieb.map((eintrag, stelle) => (
|
||||
<li key={stelle}>
|
||||
<span>{eintrag.text}</span>
|
||||
<span className="leise">{eintrag.stunden > 0 ? `${zahlText(eintrag.stunden)} h` : '–'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="karte stapel-eng" aria-labelledby="schule-lesen">
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id="schule-lesen" style={{ margin: 0, fontSize: '1rem' }}>
|
||||
Berufsschule
|
||||
</h2>
|
||||
<span className="leise">{zahlText(summeSchule(blatt))} h</span>
|
||||
</div>
|
||||
|
||||
{blatt.schule.length === 0 ? (
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
Keine Eintragung.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="leseliste">
|
||||
{blatt.schule.map((eintrag, stelle) => (
|
||||
<li key={stelle}>
|
||||
<span>
|
||||
{eintrag.lernfeld ? <strong>{eintrag.lernfeld} </strong> : null}
|
||||
{eintrag.themen}
|
||||
</span>
|
||||
<span className="leise">{eintrag.stunden > 0 ? `${zahlText(eintrag.stunden)} h` : '–'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{blatt.sonstiges ? (
|
||||
<section className="karte stapel-eng" aria-labelledby="sonstiges-lesen">
|
||||
<h2 id="sonstiges-lesen" style={{ margin: 0, fontSize: '1rem' }}>
|
||||
Sonstiges
|
||||
</h2>
|
||||
<p className="notiz-anzeige">{blatt.sonstiges}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="karte stapel-eng" aria-labelledby="pruefung-lesen">
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id="pruefung-lesen" style={{ margin: 0, fontSize: '1rem' }}>
|
||||
Prüfung
|
||||
</h2>
|
||||
<strong>{zahlText(summeStunden(blatt))} Stunden gesamt</strong>
|
||||
</div>
|
||||
|
||||
{warnungen.length === 0 ? (
|
||||
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
|
||||
✓ Nichts zu beanstanden.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="warnliste">
|
||||
{warnungen.map((warnung, stelle) => (
|
||||
<li key={stelle} data-schwere={warnung.schwere}>
|
||||
<span className="warnzeichen" aria-hidden="true">
|
||||
{warnung.schwere === 'warnung' ? '!' : 'i'}
|
||||
</span>
|
||||
<span>
|
||||
{warnung.text}
|
||||
{warnung.auszug ? <em> – „{warnung.auszug}“</em> : null}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stellt zusammen, was die Anwendung über diese Woche weiß. */
|
||||
function sammleVorschlaege(azubiId: string, von: string, bis: string) {
|
||||
const alle = inhalte();
|
||||
|
||||
const notizen = notizenIm(azubiId, von, bis).map((n) => ({
|
||||
datum: n.datum,
|
||||
text: n.text,
|
||||
stunden: n.stunden,
|
||||
}));
|
||||
|
||||
// Praxisaufgaben, die in dieser Woche abgegeben oder abgenommen wurden.
|
||||
const staende = aufgabenStaende(azubiId);
|
||||
const aufgaben = [...staende.values()]
|
||||
.filter((s) => {
|
||||
const zeitpunkt = (s.abgenommenAm ?? s.abgegebenAm ?? '').slice(0, 10);
|
||||
return zeitpunkt >= von && zeitpunkt <= bis;
|
||||
})
|
||||
.map((s) => {
|
||||
const aufgabe = alle.aufgabeNach.get(s.aufgabeId);
|
||||
return {
|
||||
id: s.aufgabeId,
|
||||
titel: aufgabe?.titel ?? s.aufgabeId,
|
||||
zeitrahmen: aufgabe?.zeitrahmen ?? '',
|
||||
status: s.status,
|
||||
};
|
||||
});
|
||||
|
||||
const checklisten = laeufeVon(azubiId)
|
||||
.filter((l) => l.abgeschlossenAm !== null && l.datum >= von && l.datum <= bis)
|
||||
.map((l) => ({ id: l.id, titel: l.titel, ticket: l.ticket, kunde: l.kunde }));
|
||||
|
||||
// Handouts der Wochen, die in diesem Zeitraum lagen – nur die gelesenen.
|
||||
const ausbildung = ausbildungNachAzubi(azubiId);
|
||||
const gelesen = gelesenSet(azubiId);
|
||||
const wochenImZeitraum = ausbildung
|
||||
? planVon(ausbildung.id).filter((z) => z.geplantVon <= bis && z.geplantBis >= von)
|
||||
: [];
|
||||
|
||||
const handouts = wochenImZeitraum
|
||||
.flatMap((z) => alle.wocheNach.get(z.wochenNummer)?.handouts ?? [])
|
||||
.filter((id) => gelesen.has(id))
|
||||
.map((id) => ({ id, titel: alle.handoutNach.get(id)?.titel ?? id }));
|
||||
|
||||
const lernfelder = [
|
||||
...new Set(
|
||||
wochenImZeitraum
|
||||
.flatMap((z) => alle.wocheNach.get(z.wochenNummer)?.handouts ?? [])
|
||||
.map((id) => alle.handoutNach.get(id)?.lernfeld)
|
||||
.filter((lf): lf is string => Boolean(lf)),
|
||||
),
|
||||
].map((id) => ({ id, titel: alle.lernfeldNach.get(id)?.titel ?? id }));
|
||||
|
||||
const rohdaten: Rohdaten = { tagesnotizen: notizen, aufgaben, checklisten, handouts, lernfelder };
|
||||
return baueVorschlaege(rohdaten);
|
||||
}
|
||||
|
||||
function zahlText(wert: number): string {
|
||||
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
|
||||
import { alsWochenblatt } from '@/lib/repos/berichtsheft';
|
||||
import { berichtsheftPdf } from '@/lib/pdf/berichtsheft';
|
||||
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
|
||||
import { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
|
||||
import { blattZumLesen } from '../../aktionen';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** Ein einzelnes Wochenblatt als PDF. Rechteprüfung über `blattZumLesen`. */
|
||||
export async function GET(_anfrage: Request, kontext: { params: Promise<{ berichtId: string }> }) {
|
||||
const { berichtId } = await kontext.params;
|
||||
|
||||
const gelesen = await blattZumLesen(berichtId);
|
||||
if (!gelesen) return new Response('Nicht gefunden.', { status: 404 });
|
||||
|
||||
const { bericht } = gelesen;
|
||||
const azubi = benutzerNachId(bericht.benutzerId);
|
||||
const ausbildung = ausbildungNachAzubi(bericht.benutzerId);
|
||||
const ausbilder = ausbildung ? benutzerNachId(ausbildung.ausbilderId) : null;
|
||||
const abzeichner = bericht.abgezeichnetVon ? benutzerNachId(bericht.abgezeichnetVon) : null;
|
||||
|
||||
const pdf = await berichtsheftPdf([
|
||||
{
|
||||
nr: bericht.nr,
|
||||
von: alsDatumText(bericht.von),
|
||||
bis: alsDatumText(bericht.bis),
|
||||
azubi: azubi?.name ?? 'unbekannt',
|
||||
ausbilder: ausbilder?.name ?? '',
|
||||
beruf: ausbildung?.beruf ?? 'Fachinformatiker/-in für Systemintegration',
|
||||
ausbildungsjahr: ausbildung ? ausbildungsjahr(ausbildung.beginn, bericht.von) : null,
|
||||
status: bericht.status,
|
||||
anmerkung: bericht.anmerkung,
|
||||
abgezeichnetVon: abzeichner?.name ?? null,
|
||||
abgezeichnetAm: bericht.abgezeichnetAm ? alsZeitpunktText(bericht.abgezeichnetAm) : null,
|
||||
blatt: alsWochenblatt(bericht),
|
||||
},
|
||||
]);
|
||||
|
||||
const dateiname = `Ausbildungsnachweis_${String(bericht.nr).padStart(3, '0')}_${bericht.von}.pdf`;
|
||||
|
||||
return new Response(new Uint8Array(pdf), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Length': String(pdf.byteLength),
|
||||
'Content-Disposition': `inline; filename="${dateiname}"`,
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { erfordereBenutzer, erfordereBewertungsrecht, ausbildungVonAzubi } from '@/lib/auth/rechte';
|
||||
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
|
||||
import {
|
||||
abzeichnen,
|
||||
berichtNachId,
|
||||
einreichen,
|
||||
inhaltSpeichern,
|
||||
notizSetzen,
|
||||
sorgeFuerBlatt,
|
||||
zurueckgeben,
|
||||
} from '@/lib/repos/berichtsheft';
|
||||
import type { Wochenblatt } from '@/lib/berichtsheft/pruefung';
|
||||
|
||||
export type Rueckmeldung = { fehler?: string; erfolg?: string };
|
||||
|
||||
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** Zum Bearbeiten: nur das eigene Blatt, und nur solange es nicht abgezeichnet ist. */
|
||||
async function blattZumBearbeiten(berichtId: string) {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const bericht = berichtNachId(berichtId);
|
||||
if (!bericht) throw new Error('Dieses Wochenblatt gibt es nicht.');
|
||||
if (bericht.benutzerId !== benutzer.id) throw new Error('Kein Zugriff auf dieses Wochenblatt.');
|
||||
return { benutzer, bericht };
|
||||
}
|
||||
|
||||
/** Zum Lesen: das eigene Blatt oder das eines betreuten Azubis. */
|
||||
export async function blattZumLesen(berichtId: string) {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const bericht = berichtNachId(berichtId);
|
||||
if (!bericht) return null;
|
||||
|
||||
const bezug = ausbildungVonAzubi(bericht.benutzerId);
|
||||
if (!darfAzubiDatenSehen(benutzer, bericht.benutzerId, bezug)) return null;
|
||||
|
||||
return { benutzer, bericht };
|
||||
}
|
||||
|
||||
function aktualisiere(berichtId?: string) {
|
||||
revalidatePath('/berichtsheft');
|
||||
if (berichtId) revalidatePath(`/berichtsheft/${berichtId}`);
|
||||
revalidatePath('/');
|
||||
}
|
||||
|
||||
// --- Tagesnotizen ----------------------------------------------------------
|
||||
|
||||
export async function tagesnotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
if (benutzer.rolle !== 'azubi') return { fehler: 'Tagesnotizen schreibt der Azubi.' };
|
||||
|
||||
const datum = String(formular.get('datum') ?? '').trim();
|
||||
if (!datumMuster.test(datum)) return { fehler: 'Ungültiges Datum.' };
|
||||
|
||||
const text = String(formular.get('text') ?? '');
|
||||
if (text.length > 2000) return { fehler: 'Die Notiz ist zu lang – höchstens 2000 Zeichen.' };
|
||||
|
||||
const stunden = zahl(formular.get('stunden'));
|
||||
if (stunden > 24) return { fehler: 'Mehr als 24 Stunden an einem Tag sind nicht plausibel.' };
|
||||
|
||||
notizSetzen(benutzer.id, datum, text, stunden);
|
||||
aktualisiere();
|
||||
return { erfolg: text.trim() ? 'Notiz gespeichert.' : 'Notiz entfernt.' };
|
||||
}
|
||||
|
||||
// --- Wochenblatt -----------------------------------------------------------
|
||||
|
||||
export async function blattAnlegen(formular: FormData): Promise<void> {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
if (benutzer.rolle !== 'azubi') return;
|
||||
|
||||
const datum = String(formular.get('datum') ?? '').trim();
|
||||
if (!datumMuster.test(datum)) return;
|
||||
|
||||
const bericht = sorgeFuerBlatt(benutzer.id, datum);
|
||||
aktualisiere(bericht.id);
|
||||
redirect(`/berichtsheft/${bericht.id}`);
|
||||
}
|
||||
|
||||
/** Liest das Blatt aus dem Formular. Leere Zeilen fliegen raus. */
|
||||
function leseBlatt(formular: FormData): Wochenblatt {
|
||||
const betriebTexte = formular.getAll('betriebText').map(String);
|
||||
const betriebStunden = formular.getAll('betriebStunden');
|
||||
const schuleLernfelder = formular.getAll('schuleLernfeld').map(String);
|
||||
const schuleThemen = formular.getAll('schuleThemen').map(String);
|
||||
const schuleStunden = formular.getAll('schuleStunden');
|
||||
|
||||
return {
|
||||
betrieb: betriebTexte
|
||||
.map((text, stelle) => ({ text: text.trim(), stunden: zahl(betriebStunden[stelle]) }))
|
||||
.filter((e) => e.text !== '' || e.stunden > 0),
|
||||
schule: schuleLernfelder
|
||||
.map((lernfeld, stelle) => ({
|
||||
lernfeld: lernfeld.trim(),
|
||||
themen: (schuleThemen[stelle] ?? '').trim(),
|
||||
stunden: zahl(schuleStunden[stelle]),
|
||||
}))
|
||||
.filter((e) => e.lernfeld !== '' || e.themen !== '' || e.stunden > 0),
|
||||
sonstiges: String(formular.get('sonstiges') ?? '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function blattSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const berichtId = String(formular.get('berichtId') ?? '');
|
||||
const { bericht } = await blattZumBearbeiten(berichtId);
|
||||
|
||||
if (bericht.status === 'abgezeichnet') {
|
||||
return { fehler: 'Das Blatt ist abgezeichnet und damit gesperrt.' };
|
||||
}
|
||||
|
||||
const blatt = leseBlatt(formular);
|
||||
if (blatt.betrieb.length > 30) return { fehler: 'Mehr als 30 Tätigkeiten in einer Woche sind zu viel.' };
|
||||
if (blatt.sonstiges.length > 2000) return { fehler: 'Der Text unter „Sonstiges" ist zu lang.' };
|
||||
|
||||
inhaltSpeichern(berichtId, blatt);
|
||||
aktualisiere(berichtId);
|
||||
return { erfolg: 'Gespeichert.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Einreichen.
|
||||
*
|
||||
* Die Prüfung ist eine **Warnung, keine Sperre** – deshalb wird hier nichts
|
||||
* geprüft, was das Einreichen verhindern könnte. Der Azubi hat die Warnungen
|
||||
* auf der Seite gesehen; ob er sie beherzigt, entscheidet er.
|
||||
*/
|
||||
export async function blattEinreichen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const berichtId = String(formular.get('berichtId') ?? '');
|
||||
const { bericht } = await blattZumBearbeiten(berichtId);
|
||||
|
||||
if (bericht.status === 'abgezeichnet') return { fehler: 'Das Blatt ist schon abgezeichnet.' };
|
||||
if (bericht.status === 'eingereicht') return { fehler: 'Das Blatt liegt schon beim Ausbilder.' };
|
||||
|
||||
// Vor dem Einreichen den letzten Stand übernehmen, damit nichts verlorengeht.
|
||||
inhaltSpeichern(berichtId, leseBlatt(formular));
|
||||
einreichen(berichtId);
|
||||
|
||||
aktualisiere(berichtId);
|
||||
return { erfolg: 'Eingereicht. Dein Ausbilder sieht das Blatt jetzt unter „Was auf dich wartet".' };
|
||||
}
|
||||
|
||||
export async function blattZurueckziehen(formular: FormData): Promise<void> {
|
||||
const berichtId = String(formular.get('berichtId') ?? '');
|
||||
const { bericht } = await blattZumBearbeiten(berichtId);
|
||||
if (bericht.status !== 'eingereicht') return;
|
||||
|
||||
zurueckgeben(berichtId, bericht.anmerkung ?? '');
|
||||
aktualisiere(berichtId);
|
||||
}
|
||||
|
||||
// --- Abzeichnen durch den Ausbilder ----------------------------------------
|
||||
|
||||
export async function blattAbzeichnen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const ausbilder = await erfordereBenutzer();
|
||||
const berichtId = String(formular.get('berichtId') ?? '');
|
||||
|
||||
const bericht = berichtNachId(berichtId);
|
||||
if (!bericht) return { fehler: 'Dieses Wochenblatt gibt es nicht.' };
|
||||
erfordereBewertungsrecht(ausbilder, bericht.benutzerId);
|
||||
|
||||
if (bericht.status === 'abgezeichnet') return { fehler: 'Das Blatt ist schon abgezeichnet.' };
|
||||
|
||||
const anmerkung = String(formular.get('anmerkung') ?? '').trim();
|
||||
if (anmerkung.length > 2000) return { fehler: 'Die Anmerkung ist zu lang.' };
|
||||
|
||||
abzeichnen(berichtId, ausbilder.id, anmerkung || null);
|
||||
aktualisiere(berichtId);
|
||||
return { erfolg: 'Abgezeichnet. Das Blatt ist damit gesperrt.' };
|
||||
}
|
||||
|
||||
export async function blattZurueckgeben(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const ausbilder = await erfordereBenutzer();
|
||||
const berichtId = String(formular.get('berichtId') ?? '');
|
||||
|
||||
const bericht = berichtNachId(berichtId);
|
||||
if (!bericht) return { fehler: 'Dieses Wochenblatt gibt es nicht.' };
|
||||
erfordereBewertungsrecht(ausbilder, bericht.benutzerId);
|
||||
|
||||
const anmerkung = String(formular.get('anmerkung') ?? '').trim();
|
||||
if (!anmerkung) {
|
||||
return { fehler: 'Ohne Anmerkung weiß der Azubi nicht, was er ändern soll. Schreib bitte einen Satz dazu.' };
|
||||
}
|
||||
if (anmerkung.length > 2000) return { fehler: 'Die Anmerkung ist zu lang.' };
|
||||
|
||||
zurueckgeben(berichtId, anmerkung);
|
||||
aktualisiere(berichtId);
|
||||
return { erfolg: 'Zurückgegeben. Der Azubi kann das Blatt jetzt wieder bearbeiten.' };
|
||||
}
|
||||
|
||||
function zahl(wert: FormDataEntryValue | null | undefined): number {
|
||||
const roh = String(wert ?? '').replace(',', '.').trim();
|
||||
const gelesen = Number(roh);
|
||||
return Number.isFinite(gelesen) && gelesen > 0 ? Math.round(gelesen * 100) / 100 : 0;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
|
||||
import { alsWochenblatt, berichteVon, fehlendeWochen, notizenIm } from '@/lib/repos/berichtsheft';
|
||||
import { summeStunden } from '@/lib/berichtsheft/pruefung';
|
||||
import { alsDatumText, heute, kalenderwocheText, montagDerWoche, werktage, wochentagText } from '@/lib/zeit';
|
||||
import { Tagesnotizen, type Tagesfeld } from './Tagesnotizen';
|
||||
import { Formulierungshilfe } from './Formulierungshilfe';
|
||||
import { blattAnlegen } from './aktionen';
|
||||
import { BerichtsMarke } from './BerichtsMarke';
|
||||
|
||||
export const metadata: Metadata = { title: 'Berichtsheft' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function BerichtsheftSeite() {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
|
||||
return benutzer.rolle === 'ausbilder' ? (
|
||||
<AusbilderSicht ausbilderId={benutzer.id} />
|
||||
) : (
|
||||
<AzubiSicht azubiId={benutzer.id} />
|
||||
);
|
||||
}
|
||||
|
||||
// --- Azubi -----------------------------------------------------------------
|
||||
|
||||
function AzubiSicht({ azubiId }: { azubiId: string }) {
|
||||
const ausbildung = ausbildungNachAzubi(azubiId);
|
||||
const berichte = berichteVon(azubiId);
|
||||
const heuteDatum = heute();
|
||||
|
||||
const tage = werktage(heuteDatum);
|
||||
const notizen = notizenIm(azubiId, tage[0]!, tage[4]!);
|
||||
const notizNach = new Map(notizen.map((n) => [n.datum, n]));
|
||||
|
||||
const felder: Tagesfeld[] = tage.map((datum) => ({
|
||||
datum,
|
||||
wochentag: wochentagText(datum),
|
||||
datumText: alsDatumText(datum),
|
||||
text: notizNach.get(datum)?.text ?? '',
|
||||
stunden: notizNach.get(datum)?.stunden ?? 0,
|
||||
istHeute: datum === heuteDatum,
|
||||
}));
|
||||
|
||||
const dieseWoche = berichte.find((b) => b.von === montagDerWoche(heuteDatum));
|
||||
const fehlend = ausbildung ? fehlendeWochen(azubiId, ausbildung.beginn, heuteDatum) : [];
|
||||
const abgezeichnet = berichte.filter((b) => b.status === 'abgezeichnet').length;
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Berichtsheft</h1>
|
||||
<p className="leise">
|
||||
{berichte.length} {berichte.length === 1 ? 'Wochenblatt' : 'Wochenblätter'} · {abgezeichnet} abgezeichnet
|
||||
{fehlend.length > 0 ? ` · ${fehlend.length} Wochen ohne Blatt` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{fehlend.length > 0 ? (
|
||||
<div className="hinweis hinweis-warnung">
|
||||
<strong>
|
||||
Für {fehlend.length} vergangene {fehlend.length === 1 ? 'Woche' : 'Wochen'} gibt es noch kein Blatt.
|
||||
</strong>{' '}
|
||||
Ohne geführten Nachweis gibt es keine Zulassung zur Abschlussprüfung – das ist der Teil, der in der
|
||||
Praxis am häufigsten schiefgeht.
|
||||
<div className="reihe" style={{ marginTop: '0.6rem' }}>
|
||||
{fehlend.slice(0, 6).map((montag) => (
|
||||
<form key={montag} action={blattAnlegen}>
|
||||
<input type="hidden" name="datum" value={montag} />
|
||||
<button type="submit" className="knopf knopf-zweit">
|
||||
{kalenderwocheText(montag)} nachtragen
|
||||
</button>
|
||||
</form>
|
||||
))}
|
||||
{fehlend.length > 6 ? <span className="leise">… und {fehlend.length - 6} weitere</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="karte-grund stapel" aria-labelledby="notizen">
|
||||
<div>
|
||||
<h2 id="notizen" style={{ marginBottom: '0.15rem' }}>
|
||||
Diese Woche in Stichworten
|
||||
</h2>
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Zwei Minuten am Tag. Freitags baust du daraus mit einem Klick das Wochenblatt – das ist deutlich
|
||||
weniger Arbeit, als sich am Freitag an den Dienstag zu erinnern.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tagesnotizen tage={felder} />
|
||||
</section>
|
||||
|
||||
<section className="stapel" aria-labelledby="blaetter">
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id="blaetter" style={{ margin: 0 }}>
|
||||
Wochenblätter
|
||||
</h2>
|
||||
|
||||
{dieseWoche ? (
|
||||
<Link href={`/berichtsheft/${dieseWoche.id}`} className="knopf">
|
||||
Blatt dieser Woche öffnen
|
||||
</Link>
|
||||
) : (
|
||||
<form action={blattAnlegen}>
|
||||
<input type="hidden" name="datum" value={heuteDatum} />
|
||||
<button type="submit" className="knopf knopf-akzent">
|
||||
Blatt für diese Woche anlegen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{berichte.length === 0 ? (
|
||||
<p className="karte" style={{ marginBottom: 0 }}>
|
||||
Noch kein Wochenblatt. Leg das erste an, sobald die Woche läuft.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="blattliste">
|
||||
{berichte.map((bericht) => {
|
||||
const blatt = alsWochenblatt(bericht);
|
||||
return (
|
||||
<li key={bericht.id}>
|
||||
<Link href={`/berichtsheft/${bericht.id}`} className="blattzeile-link">
|
||||
<span className="blatt-nr">Nr. {bericht.nr}</span>
|
||||
<span className="blatt-zeitraum">
|
||||
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
|
||||
<span className="leise" style={{ display: 'block' }}>
|
||||
{kalenderwocheText(bericht.von)} · {blatt.betrieb.length} Tätigkeiten ·{' '}
|
||||
{zahlText(summeStunden(blatt))} h
|
||||
</span>
|
||||
</span>
|
||||
<BerichtsMarke status={bericht.status} />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{berichte.length > 0 ? (
|
||||
<section className="karte stapel-eng nicht-drucken" aria-labelledby="export">
|
||||
<h2 id="export" style={{ marginBottom: 0, fontSize: '1rem' }}>
|
||||
Ganzen Zeitraum ausgeben
|
||||
</h2>
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Alle Blätter als ein PDF, ein Blatt je Seite – zum Ausdrucken und Abheften.
|
||||
</p>
|
||||
<div>
|
||||
<a
|
||||
href={`/berichtsheft/pdf?von=${berichte.at(-1)!.von}&bis=${berichte[0]!.bis}`}
|
||||
className="knopf knopf-zweit"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Alle {berichte.length} Blätter als PDF
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<Formulierungshilfe />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Ausbilder -------------------------------------------------------------
|
||||
|
||||
function AusbilderSicht({ ausbilderId }: { ausbilderId: string }) {
|
||||
const meine = ausbildungenVonAusbilder(ausbilderId);
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Berichtshefte</h1>
|
||||
<p className="leise">Eingereichte Blätter abzeichnen oder zur Nacharbeit zurückgeben.</p>
|
||||
</div>
|
||||
|
||||
{meine.length === 0 ? (
|
||||
<p className="hinweis hinweis-warnung">
|
||||
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
|
||||
</p>
|
||||
) : (
|
||||
meine.map(({ azubi, ausbildung }) => {
|
||||
const berichte = berichteVon(azubi.id);
|
||||
const offene = berichte.filter((b) => b.status === 'eingereicht');
|
||||
const fehlend = fehlendeWochen(azubi.id, ausbildung.beginn);
|
||||
|
||||
return (
|
||||
<section key={azubi.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
|
||||
{azubi.name}
|
||||
</h2>
|
||||
<div className="reihe">
|
||||
{offene.length > 0 ? (
|
||||
<span className="marke-status" data-ton="warnung">
|
||||
● {offene.length} zum Abzeichnen
|
||||
</span>
|
||||
) : null}
|
||||
{fehlend.length > 0 ? (
|
||||
<span className="marke-status" data-ton="fehler">
|
||||
! {fehlend.length} Wochen ohne Blatt
|
||||
</span>
|
||||
) : null}
|
||||
{berichte.length > 0 ? (
|
||||
<a
|
||||
href={`/berichtsheft/pdf?azubi=${azubi.id}&von=${berichte.at(-1)!.von}&bis=${berichte[0]!.bis}`}
|
||||
className="knopf knopf-zweit"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Alles als PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{berichte.length === 0 ? (
|
||||
<p className="karte" style={{ marginBottom: 0 }}>
|
||||
Noch kein Wochenblatt.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="blattliste">
|
||||
{berichte.map((bericht) => {
|
||||
const blatt = alsWochenblatt(bericht);
|
||||
return (
|
||||
<li key={bericht.id}>
|
||||
<Link href={`/berichtsheft/${bericht.id}`} className="blattzeile-link">
|
||||
<span className="blatt-nr">Nr. {bericht.nr}</span>
|
||||
<span className="blatt-zeitraum">
|
||||
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
|
||||
<span className="leise" style={{ display: 'block' }}>
|
||||
{blatt.betrieb.length} Tätigkeiten · {zahlText(summeStunden(blatt))} h
|
||||
</span>
|
||||
</span>
|
||||
<BerichtsMarke status={bericht.status} />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function zahlText(wert: number): string {
|
||||
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { erfordereBenutzer, ausbildungVonAzubi } from '@/lib/auth/rechte';
|
||||
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
|
||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||
import { alsWochenblatt, berichteVon } from '@/lib/repos/berichtsheft';
|
||||
import { berichtsheftPdf, type Blattangaben } from '@/lib/pdf/berichtsheft';
|
||||
import { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
|
||||
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Ein ganzer Zeitraum als PDF, ein Blatt je Seite.
|
||||
*
|
||||
* /berichtsheft/pdf?von=2026-08-01&bis=2026-12-31[&azubi=…]
|
||||
*
|
||||
* Ohne `azubi` das eigene Berichtsheft. Der Ausbilder gibt die Kennung an –
|
||||
* geprüft wird sie serverseitig gegen das Ausbildungsverhältnis.
|
||||
*/
|
||||
export async function GET(anfrage: Request) {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const adresse = new URL(anfrage.url);
|
||||
|
||||
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (adresse.searchParams.get('azubi') ?? '');
|
||||
if (!azubiId) return new Response('Nicht gefunden.', { status: 404 });
|
||||
|
||||
const bezug = ausbildungVonAzubi(azubiId);
|
||||
if (!darfAzubiDatenSehen(benutzer, azubiId, bezug)) {
|
||||
return new Response('Nicht gefunden.', { status: 404 });
|
||||
}
|
||||
|
||||
const von = adresse.searchParams.get('von') ?? '';
|
||||
const bis = adresse.searchParams.get('bis') ?? '';
|
||||
if (!datumMuster.test(von) || !datumMuster.test(bis) || von > bis) {
|
||||
return new Response('Ungültiger Zeitraum.', { status: 400 });
|
||||
}
|
||||
|
||||
const azubi = benutzerNachId(azubiId);
|
||||
const ausbilder = bezug ? benutzerNachId(bezug.ausbilderId) : null;
|
||||
|
||||
// Aufsteigend, damit das Heft in der richtigen Reihenfolge ausgedruckt wird.
|
||||
const berichte = berichteVon(azubiId)
|
||||
.filter((b) => b.bis >= von && b.von <= bis)
|
||||
.sort((a, b) => a.nr - b.nr);
|
||||
|
||||
const blaetter: Blattangaben[] = berichte.map((bericht) => {
|
||||
const abzeichner = bericht.abgezeichnetVon ? benutzerNachId(bericht.abgezeichnetVon) : null;
|
||||
return {
|
||||
nr: bericht.nr,
|
||||
von: alsDatumText(bericht.von),
|
||||
bis: alsDatumText(bericht.bis),
|
||||
azubi: azubi?.name ?? 'unbekannt',
|
||||
ausbilder: ausbilder?.name ?? '',
|
||||
beruf: bezug?.beruf ?? 'Fachinformatiker/-in für Systemintegration',
|
||||
ausbildungsjahr: bezug ? ausbildungsjahr(bezug.beginn, bericht.von) : null,
|
||||
status: bericht.status,
|
||||
anmerkung: bericht.anmerkung,
|
||||
abgezeichnetVon: abzeichner?.name ?? null,
|
||||
abgezeichnetAm: bericht.abgezeichnetAm ? alsZeitpunktText(bericht.abgezeichnetAm) : null,
|
||||
blatt: alsWochenblatt(bericht),
|
||||
};
|
||||
});
|
||||
|
||||
const pdf = await berichtsheftPdf(blaetter, `${alsDatumText(von)} bis ${alsDatumText(bis)}`);
|
||||
const dateiname = `Ausbildungsnachweis_${von}_bis_${bis}.pdf`;
|
||||
|
||||
return new Response(new Uint8Array(pdf), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Length': String(pdf.byteLength),
|
||||
'Content-Disposition': `inline; filename="${dateiname}"`,
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
|
||||
import {
|
||||
durchlaufAbschliessen,
|
||||
durchlaufStarten,
|
||||
kopfdatenSpeichern,
|
||||
schrittBemerkung,
|
||||
type Rueckmeldung,
|
||||
} from './aktionen';
|
||||
|
||||
export function StartFormular({
|
||||
checklisteId,
|
||||
mitTicket,
|
||||
heuteDatum,
|
||||
}: {
|
||||
checklisteId: string;
|
||||
mitTicket: boolean;
|
||||
heuteDatum: string;
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(durchlaufStarten, {});
|
||||
|
||||
return (
|
||||
<form action={aktion} className="stapel-eng">
|
||||
<Meldung zustand={zustand} />
|
||||
<input type="hidden" name="checklisteId" value={checklisteId} />
|
||||
|
||||
<div className="reihe" style={{ gap: '0.4rem', alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: '1 1 130px' }}>
|
||||
<label htmlFor={`datum-${checklisteId}`}>Datum</label>
|
||||
<input id={`datum-${checklisteId}`} name="datum" type="date" defaultValue={heuteDatum} required />
|
||||
</div>
|
||||
|
||||
{mitTicket ? (
|
||||
<div style={{ flex: '1 1 110px' }}>
|
||||
<label htmlFor={`ticket-${checklisteId}`}>Ticket</label>
|
||||
<input id={`ticket-${checklisteId}`} name="ticket" type="text" maxLength={80} placeholder="z. B. 4711" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="feld" style={{ marginTop: 0 }}>
|
||||
<label htmlFor={`kunde-${checklisteId}`}>Kunde oder Bereich</label>
|
||||
<input
|
||||
id={`kunde-${checklisteId}`}
|
||||
name="kunde"
|
||||
type="text"
|
||||
maxLength={120}
|
||||
placeholder="z. B. Buchhaltung, Halle 2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Absenden className="knopf knopf-akzent">Durchlauf starten</Absenden>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function KopfdatenFormular({
|
||||
laufId,
|
||||
datum,
|
||||
ticket,
|
||||
kunde,
|
||||
bemerkung,
|
||||
gesperrt,
|
||||
}: {
|
||||
laufId: string;
|
||||
datum: string;
|
||||
ticket: string;
|
||||
kunde: string;
|
||||
bemerkung: string;
|
||||
gesperrt: boolean;
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(kopfdatenSpeichern, {});
|
||||
|
||||
if (gesperrt) return null;
|
||||
|
||||
return (
|
||||
<form action={aktion} className="stapel-eng">
|
||||
<Meldung zustand={zustand} />
|
||||
<input type="hidden" name="laufId" value={laufId} />
|
||||
|
||||
<div className="reihe" style={{ alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: '1 1 140px' }}>
|
||||
<label htmlFor="lauf-datum">Datum</label>
|
||||
<input id="lauf-datum" name="datum" type="date" defaultValue={datum} required />
|
||||
</div>
|
||||
<div style={{ flex: '1 1 120px' }}>
|
||||
<label htmlFor="lauf-ticket">Ticket</label>
|
||||
<input id="lauf-ticket" name="ticket" type="text" maxLength={80} defaultValue={ticket} />
|
||||
</div>
|
||||
<div style={{ flex: '2 1 200px' }}>
|
||||
<label htmlFor="lauf-kunde">Kunde oder Bereich</label>
|
||||
<input id="lauf-kunde" name="kunde" type="text" maxLength={120} defaultValue={kunde} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="feld" style={{ marginTop: 0 }}>
|
||||
<label htmlFor="lauf-bemerkung">Bemerkung zum ganzen Durchlauf</label>
|
||||
<textarea
|
||||
id="lauf-bemerkung"
|
||||
name="bemerkung"
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
defaultValue={bemerkung}
|
||||
placeholder="Besonderheiten, Abweichungen, was beim nächsten Mal anders laufen sollte."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Absenden className="knopf knopf-zweit">Angaben speichern</Absenden>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function BemerkungFormular({
|
||||
laufId,
|
||||
schrittId,
|
||||
bemerkung,
|
||||
gesperrt,
|
||||
}: {
|
||||
laufId: string;
|
||||
schrittId: string;
|
||||
bemerkung: string;
|
||||
gesperrt: boolean;
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(schrittBemerkung, {});
|
||||
|
||||
if (gesperrt) {
|
||||
return bemerkung ? <p className="schritt-bemerkung">{bemerkung}</p> : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="schritt-notiz" open={Boolean(bemerkung)}>
|
||||
<summary>{bemerkung ? 'Bemerkung' : 'Bemerkung hinzufügen'}</summary>
|
||||
<form action={aktion} className="stapel-eng">
|
||||
<Meldung zustand={zustand} />
|
||||
<input type="hidden" name="laufId" value={laufId} />
|
||||
<input type="hidden" name="schrittId" value={schrittId} />
|
||||
<label htmlFor={`bem-${schrittId}`} className="nur-lesehilfe">
|
||||
Bemerkung zu diesem Schritt
|
||||
</label>
|
||||
<textarea
|
||||
id={`bem-${schrittId}`}
|
||||
name="bemerkung"
|
||||
rows={2}
|
||||
maxLength={1000}
|
||||
defaultValue={bemerkung}
|
||||
placeholder="z. B. Netzteil auffällig, Ersatz bestellt"
|
||||
/>
|
||||
<div>
|
||||
<Absenden className="knopf knopf-still">Speichern</Absenden>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function AbschlussFormular({ laufId, offen }: { laufId: string; offen: number }) {
|
||||
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(durchlaufAbschliessen, {});
|
||||
|
||||
return (
|
||||
<form action={aktion} className="stapel-eng">
|
||||
<Meldung zustand={zustand} />
|
||||
<input type="hidden" name="laufId" value={laufId} />
|
||||
<Absenden className="knopf knopf-akzent">
|
||||
{offen === 0 ? 'Durchlauf abschließen' : `Abschließen (noch ${offen} offen)`}
|
||||
</Absenden>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
|
||||
import { ausbildungVonAzubi } from '@/lib/auth/rechte';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import {
|
||||
bemerkungSetzen,
|
||||
laufAbschliessen,
|
||||
laufBemerkung,
|
||||
laufKopfdaten,
|
||||
laufLoeschen,
|
||||
laufNachId,
|
||||
laufStarten,
|
||||
laufWiederOeffnen,
|
||||
schrittSetzen,
|
||||
standVon,
|
||||
} from '@/lib/repos/checklisten';
|
||||
import { darfAbschliessen } from '@/lib/checklisten/fortschritt';
|
||||
|
||||
export type Rueckmeldung = { fehler?: string; erfolg?: string };
|
||||
|
||||
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Holt einen Durchlauf und prüft, ob der Anmelder ihn **bearbeiten** darf.
|
||||
*
|
||||
* Bearbeiten darf nur, wem der Durchlauf gehört. Der Ausbilder darf mitlesen
|
||||
* (siehe `laufZumLesen`), aber nicht für seinen Azubi abhaken – sonst stünde
|
||||
* im Nachweis eine Arbeit, die der Azubi nie gemacht hat.
|
||||
*/
|
||||
async function laufZumBearbeiten(laufId: string) {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const lauf = laufNachId(laufId);
|
||||
if (!lauf) throw new Error('Diesen Durchlauf gibt es nicht.');
|
||||
if (lauf.benutzerId !== benutzer.id) throw new Error('Kein Zugriff auf diesen Durchlauf.');
|
||||
return { benutzer, lauf };
|
||||
}
|
||||
|
||||
/** Zum Lesen: der eigene Durchlauf oder der eines betreuten Azubis. */
|
||||
export async function laufZumLesen(laufId: string) {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const lauf = laufNachId(laufId);
|
||||
if (!lauf) return null;
|
||||
|
||||
const bezug = ausbildungVonAzubi(lauf.benutzerId);
|
||||
if (!darfAzubiDatenSehen(benutzer, lauf.benutzerId, bezug)) return null;
|
||||
|
||||
return { benutzer, lauf };
|
||||
}
|
||||
|
||||
function aktualisiere(laufId: string) {
|
||||
revalidatePath('/checklisten');
|
||||
revalidatePath(`/checklisten/lauf/${laufId}`);
|
||||
revalidatePath('/');
|
||||
}
|
||||
|
||||
export async function durchlaufStarten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
if (benutzer.rolle !== 'azubi') {
|
||||
return { fehler: 'Durchläufe führt der Azubi – der Ausbilder liest mit.' };
|
||||
}
|
||||
|
||||
const checklisteId = String(formular.get('checklisteId') ?? '');
|
||||
const vorlage = inhalte().checklisteNach.get(checklisteId);
|
||||
if (!vorlage) return { fehler: 'Diese Checkliste gibt es nicht.' };
|
||||
|
||||
const datum = String(formular.get('datum') ?? '').trim();
|
||||
if (!datumMuster.test(datum)) return { fehler: 'Bitte ein gültiges Datum wählen.' };
|
||||
|
||||
const ticket = String(formular.get('ticket') ?? '');
|
||||
const kunde = String(formular.get('kunde') ?? '');
|
||||
if (ticket.length > 80) return { fehler: 'Die Ticketangabe ist zu lang.' };
|
||||
if (kunde.length > 120) return { fehler: 'Die Kundenangabe ist zu lang.' };
|
||||
|
||||
const lauf = laufStarten({ benutzerId: benutzer.id, vorlage, datum, ticket, kunde });
|
||||
|
||||
revalidatePath('/checklisten');
|
||||
redirect(`/checklisten/lauf/${lauf.id}`);
|
||||
}
|
||||
|
||||
export async function schrittUmschalten(formular: FormData): Promise<void> {
|
||||
const laufId = String(formular.get('laufId') ?? '');
|
||||
const schrittId = String(formular.get('schrittId') ?? '');
|
||||
const erledigt = String(formular.get('erledigt') ?? '') === '1';
|
||||
|
||||
const { lauf } = await laufZumBearbeiten(laufId);
|
||||
if (lauf.abgeschlossenAm) return; // abgeschlossene Durchläufe sind Nachweis, nicht Arbeitsblatt
|
||||
|
||||
schrittSetzen(laufId, schrittId, erledigt);
|
||||
aktualisiere(laufId);
|
||||
}
|
||||
|
||||
export async function schrittBemerkung(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const laufId = String(formular.get('laufId') ?? '');
|
||||
const schrittId = String(formular.get('schrittId') ?? '');
|
||||
const bemerkung = String(formular.get('bemerkung') ?? '');
|
||||
|
||||
const { lauf } = await laufZumBearbeiten(laufId);
|
||||
if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist abgeschlossen.' };
|
||||
if (bemerkung.length > 1000) return { fehler: 'Die Bemerkung ist zu lang – höchstens 1000 Zeichen.' };
|
||||
|
||||
bemerkungSetzen(laufId, schrittId, bemerkung);
|
||||
aktualisiere(laufId);
|
||||
return { erfolg: bemerkung.trim() ? 'Bemerkung gespeichert.' : 'Bemerkung entfernt.' };
|
||||
}
|
||||
|
||||
export async function kopfdatenSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const laufId = String(formular.get('laufId') ?? '');
|
||||
const { lauf } = await laufZumBearbeiten(laufId);
|
||||
if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist abgeschlossen.' };
|
||||
|
||||
const datum = String(formular.get('datum') ?? '').trim();
|
||||
if (!datumMuster.test(datum)) return { fehler: 'Bitte ein gültiges Datum wählen.' };
|
||||
|
||||
const bemerkung = String(formular.get('bemerkung') ?? '');
|
||||
if (bemerkung.length > 2000) return { fehler: 'Die Bemerkung ist zu lang – höchstens 2000 Zeichen.' };
|
||||
|
||||
laufKopfdaten(laufId, {
|
||||
datum,
|
||||
ticket: String(formular.get('ticket') ?? ''),
|
||||
kunde: String(formular.get('kunde') ?? ''),
|
||||
});
|
||||
laufBemerkung(laufId, bemerkung);
|
||||
|
||||
aktualisiere(laufId);
|
||||
return { erfolg: 'Angaben gespeichert.' };
|
||||
}
|
||||
|
||||
export async function durchlaufAbschliessen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const laufId = String(formular.get('laufId') ?? '');
|
||||
const { lauf } = await laufZumBearbeiten(laufId);
|
||||
if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist schon abgeschlossen.' };
|
||||
|
||||
const stand = standVon(laufId);
|
||||
if (!darfAbschliessen(stand)) {
|
||||
const offen = stand.gesamt - stand.erledigt;
|
||||
return {
|
||||
fehler: `Es ${offen === 1 ? 'ist noch ein Schritt' : `sind noch ${offen} Schritte`} offen. Ein Durchlauf wird erst abgeschlossen, wenn alles abgehakt ist.`,
|
||||
};
|
||||
}
|
||||
|
||||
laufAbschliessen(laufId);
|
||||
aktualisiere(laufId);
|
||||
return { erfolg: 'Durchlauf abgeschlossen. Er bleibt als Nachweis erhalten.' };
|
||||
}
|
||||
|
||||
export async function durchlaufWiederOeffnen(formular: FormData): Promise<void> {
|
||||
const laufId = String(formular.get('laufId') ?? '');
|
||||
await laufZumBearbeiten(laufId);
|
||||
laufWiederOeffnen(laufId);
|
||||
aktualisiere(laufId);
|
||||
}
|
||||
|
||||
export async function durchlaufVerwerfen(formular: FormData): Promise<void> {
|
||||
const laufId = String(formular.get('laufId') ?? '');
|
||||
const { lauf } = await laufZumBearbeiten(laufId);
|
||||
// Ein abgeschlossener Durchlauf ist ein Nachweis und wird nicht gelöscht.
|
||||
if (lauf.abgeschlossenAm) return;
|
||||
|
||||
laufLoeschen(laufId);
|
||||
revalidatePath('/checklisten');
|
||||
redirect('/checklisten');
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||
import { standVon } from '@/lib/repos/checklisten';
|
||||
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
|
||||
import { AbschlussFormular, BemerkungFormular, KopfdatenFormular } from '../../Formulare';
|
||||
import { durchlaufVerwerfen, durchlaufWiederOeffnen, laufZumLesen, schrittUmschalten } from '../../aktionen';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ laufId: string }> }): Promise<Metadata> {
|
||||
const { laufId } = await params;
|
||||
const gelesen = await laufZumLesen(laufId);
|
||||
return { title: gelesen ? gelesen.lauf.titel : 'Durchlauf' };
|
||||
}
|
||||
|
||||
export default async function LaufSeite({ params }: { params: Promise<{ laufId: string }> }) {
|
||||
const { laufId } = await params;
|
||||
|
||||
// Enthält die Rechteprüfung: Wer nicht darf, bekommt null – und damit 404.
|
||||
const gelesen = await laufZumLesen(laufId);
|
||||
if (!gelesen) notFound();
|
||||
|
||||
const { benutzer, lauf } = gelesen;
|
||||
const eigener = lauf.benutzerId === benutzer.id;
|
||||
const gesperrt = Boolean(lauf.abgeschlossenAm) || !eigener;
|
||||
|
||||
const alle = inhalte();
|
||||
const vorlage = alle.checklisteNach.get(lauf.checklisteId);
|
||||
const stand = standVon(laufId);
|
||||
const bearbeiter = eigener ? benutzer : benutzerNachId(lauf.benutzerId);
|
||||
|
||||
const positionen = (vorlage?.rahmenplan ?? [])
|
||||
.map((p) => alle.positionNach.get(p))
|
||||
.filter((p) => p !== undefined);
|
||||
|
||||
return (
|
||||
<article className="stapel">
|
||||
<nav className="leise nicht-drucken" aria-label="Wo du bist">
|
||||
<Link href="/checklisten">Checklisten</Link>
|
||||
{!eigener && bearbeiter ? ` › ${bearbeiter.name}` : ''}
|
||||
</nav>
|
||||
|
||||
<header className="stapel-eng">
|
||||
<div className="reihe" style={{ gap: '0.6rem' }}>
|
||||
{lauf.abgeschlossenAm ? (
|
||||
<span className="marke-status" data-ton="gut">
|
||||
✓ abgeschlossen am {alsZeitpunktText(lauf.abgeschlossenAm)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="marke-status" data-ton="warnung">
|
||||
◐ in Arbeit
|
||||
</span>
|
||||
)}
|
||||
<span className="leise">
|
||||
{alsDatumText(lauf.datum)}
|
||||
{lauf.ticket ? ` · Ticket ${lauf.ticket}` : ''}
|
||||
{lauf.kunde ? ` · ${lauf.kunde}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 style={{ marginBottom: '0.2rem' }}>{lauf.titel}</h1>
|
||||
{vorlage ? <p className="handout-worum">{vorlage.worum}</p> : null}
|
||||
{bearbeiter && !eigener ? <p className="leise">Bearbeitet von {bearbeiter.name}</p> : null}
|
||||
</header>
|
||||
|
||||
{!vorlage ? (
|
||||
<p className="hinweis hinweis-warnung">
|
||||
Die Vorlage <code>{lauf.checklisteId}</code> gibt es im Repository nicht mehr. Der Durchlauf bleibt
|
||||
vollständig lesbar – Titel und Schritttexte wurden beim Start mitkopiert.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{positionen.length > 0 ? (
|
||||
<div className="handout-zuordnung karte">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Ausbildungsrahmenplan</dt>
|
||||
<dd>
|
||||
{positionen.map((p) => (
|
||||
<span key={p.id} className="zuordnung-marke" title={`${p.titel} (${p.verordnung})`}>
|
||||
<strong>{p.id}</strong> {p.kurz}
|
||||
</span>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="karte stapel-eng" aria-labelledby="gesamtstand">
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id="gesamtstand" style={{ margin: 0, fontSize: '1rem' }}>
|
||||
Fortschritt
|
||||
</h2>
|
||||
<span className="leise">
|
||||
{stand.erledigt} von {stand.gesamt} Schritten · {stand.prozent} %
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="balken"
|
||||
role="progressbar"
|
||||
aria-valuenow={stand.erledigt}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={stand.gesamt}
|
||||
aria-label={`${stand.erledigt} von ${stand.gesamt} Schritten erledigt`}
|
||||
>
|
||||
<span style={{ width: `${stand.prozent}%` }} />
|
||||
</div>
|
||||
|
||||
<ul className="gruppenstand">
|
||||
{stand.gruppen.map((gruppe) => (
|
||||
<li key={gruppe.gruppeId}>
|
||||
<span>{gruppe.titel}</span>
|
||||
<span className="balken" style={{ width: '70px' }}>
|
||||
<span style={{ width: `${gruppe.prozent}%` }} />
|
||||
</span>
|
||||
<span className="leise">
|
||||
{gruppe.erledigt}/{gruppe.gesamt}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{stand.gruppen.map((gruppe) => (
|
||||
<section key={gruppe.gruppeId} className="stapel-eng" aria-labelledby={`g-${gruppe.gruppeId}`}>
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id={`g-${gruppe.gruppeId}`} style={{ margin: 0 }}>
|
||||
{gruppe.titel}
|
||||
</h2>
|
||||
<span className={`marke-status${gruppe.vollstaendig ? '' : ''}`} data-ton={gruppe.vollstaendig ? 'gut' : 'neutral'}>
|
||||
{gruppe.vollstaendig ? '✓' : '○'} {gruppe.erledigt} von {gruppe.gesamt}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="schrittliste">
|
||||
{gruppe.schritte.map((schritt) => (
|
||||
<li key={schritt.schrittId} data-erledigt={schritt.erledigt ? 'ja' : 'nein'}>
|
||||
<form action={schrittUmschalten} className="schritt-haken">
|
||||
<input type="hidden" name="laufId" value={laufId} />
|
||||
<input type="hidden" name="schrittId" value={schritt.schrittId} />
|
||||
<input type="hidden" name="erledigt" value={schritt.erledigt ? '0' : '1'} />
|
||||
<button
|
||||
type="submit"
|
||||
className="hakenknopf"
|
||||
disabled={gesperrt}
|
||||
aria-pressed={schritt.erledigt}
|
||||
title={gesperrt ? 'Gesperrt' : schritt.erledigt ? 'Haken entfernen' : 'Als erledigt abhaken'}
|
||||
>
|
||||
<span aria-hidden="true">{schritt.erledigt ? '✓' : ''}</span>
|
||||
<span className="nur-lesehilfe">
|
||||
{schritt.text} – {schritt.erledigt ? 'erledigt' : 'offen'}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="schritt-inhalt">
|
||||
<span className="schritt-text">{schritt.text}</span>
|
||||
<BemerkungFormular
|
||||
laufId={laufId}
|
||||
schrittId={schritt.schrittId}
|
||||
bemerkung={schritt.bemerkung ?? ''}
|
||||
gesperrt={gesperrt}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{eigener && !lauf.abgeschlossenAm ? (
|
||||
<section className="karte-grund stapel nicht-drucken" aria-labelledby="angaben">
|
||||
<h2 id="angaben" style={{ marginBottom: 0 }}>
|
||||
Angaben zum Durchlauf
|
||||
</h2>
|
||||
<KopfdatenFormular
|
||||
laufId={laufId}
|
||||
datum={lauf.datum}
|
||||
ticket={lauf.ticket ?? ''}
|
||||
kunde={lauf.kunde ?? ''}
|
||||
bemerkung={lauf.bemerkung ?? ''}
|
||||
gesperrt={false}
|
||||
/>
|
||||
</section>
|
||||
) : lauf.bemerkung ? (
|
||||
<section className="karte stapel-eng" aria-labelledby="bemerkung">
|
||||
<h2 id="bemerkung" style={{ marginBottom: 0, fontSize: '1rem' }}>
|
||||
Bemerkung zum Durchlauf
|
||||
</h2>
|
||||
<p className="notiz-anzeige">{lauf.bemerkung}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="reihe nicht-drucken">
|
||||
<a href={`/checklisten/lauf/${laufId}/pdf`} className="knopf knopf-zweit" target="_blank" rel="noreferrer">
|
||||
Als PDF öffnen
|
||||
</a>
|
||||
|
||||
{eigener && !lauf.abgeschlossenAm ? (
|
||||
<AbschlussFormular laufId={laufId} offen={stand.gesamt - stand.erledigt} />
|
||||
) : null}
|
||||
|
||||
{eigener && lauf.abgeschlossenAm ? (
|
||||
<form action={durchlaufWiederOeffnen}>
|
||||
<input type="hidden" name="laufId" value={laufId} />
|
||||
<button type="submit" className="knopf knopf-still">
|
||||
Wieder öffnen
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{eigener && !lauf.abgeschlossenAm && stand.erledigt === 0 ? (
|
||||
<form action={durchlaufVerwerfen}>
|
||||
<input type="hidden" name="laufId" value={laufId} />
|
||||
<button type="submit" className="knopf knopf-still">
|
||||
Verwerfen
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{lauf.abgeschlossenAm ? (
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
Abgeschlossene Durchläufe bleiben als Nachweis erhalten und lassen sich nicht löschen.
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||
import { standVon } from '@/lib/repos/checklisten';
|
||||
import { checklistenPdf } from '@/lib/pdf/checkliste';
|
||||
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
|
||||
import { laufZumLesen } from '../../../aktionen';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Der Durchlauf als PDF.
|
||||
*
|
||||
* Die Rechteprüfung steckt in `laufZumLesen` – dieselbe Funktion, die auch die
|
||||
* Seite benutzt. Wer nicht darf, bekommt 404 statt 403: Ob es den Durchlauf
|
||||
* gibt, geht einen Unbefugten nichts an.
|
||||
*/
|
||||
export async function GET(_anfrage: Request, kontext: { params: Promise<{ laufId: string }> }) {
|
||||
const { laufId } = await kontext.params;
|
||||
|
||||
const gelesen = await laufZumLesen(laufId);
|
||||
if (!gelesen) return new Response('Nicht gefunden.', { status: 404 });
|
||||
|
||||
const { lauf } = gelesen;
|
||||
const vorlage = inhalte().checklisteNach.get(lauf.checklisteId);
|
||||
const bearbeiter = benutzerNachId(lauf.benutzerId);
|
||||
const stand = standVon(laufId);
|
||||
|
||||
const pdf = await checklistenPdf(
|
||||
{
|
||||
titel: lauf.titel,
|
||||
checklisteId: lauf.checklisteId,
|
||||
bearbeiter: bearbeiter?.name ?? 'unbekannt',
|
||||
datum: alsDatumText(lauf.datum),
|
||||
ticket: lauf.ticket,
|
||||
kunde: lauf.kunde,
|
||||
bemerkung: lauf.bemerkung,
|
||||
gestartetAm: alsZeitpunktText(lauf.gestartetAm),
|
||||
abgeschlossenAm: lauf.abgeschlossenAm ? alsZeitpunktText(lauf.abgeschlossenAm) : null,
|
||||
worum: vorlage?.worum,
|
||||
},
|
||||
stand,
|
||||
);
|
||||
|
||||
const dateiname = `Checkliste_${lauf.checklisteId}_${lauf.datum}.pdf`.replace(/[^\w.\-]/g, '_');
|
||||
|
||||
return new Response(new Uint8Array(pdf), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Length': String(pdf.byteLength),
|
||||
'Content-Disposition': `inline; filename="${dateiname}"`,
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { laeufeVon, standVon } from '@/lib/repos/checklisten';
|
||||
import { alsDatumText, alsZeitpunktText, heute } from '@/lib/zeit';
|
||||
import { StartFormular } from './Formulare';
|
||||
|
||||
export const metadata: Metadata = { title: 'Checklisten' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ChecklistenSeite() {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const alle = inhalte();
|
||||
const istAzubi = benutzer.rolle === 'azubi';
|
||||
|
||||
// Der Ausbilder sieht die Durchläufe seiner Azubis, der Azubi seine eigenen.
|
||||
const beteiligte = istAzubi
|
||||
? [{ id: benutzer.id, name: benutzer.name }]
|
||||
: ausbildungenVonAusbilder(benutzer.id).map(({ azubi }) => ({ id: azubi.id, name: azubi.name }));
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Checklisten</h1>
|
||||
<p className="leise">
|
||||
{alle.checklisten.length} Vorlagen für wiederkehrende Arbeiten. Aus einer Vorlage wird ein Durchlauf –
|
||||
und der bleibt als Nachweis erhalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="stapel" aria-labelledby="vorlagen">
|
||||
<h2 id="vorlagen">Vorlagen</h2>
|
||||
|
||||
<div className="raster">
|
||||
{alle.checklisten.map((vorlage) => {
|
||||
const schritte = vorlage.gruppen.reduce((summe, g) => summe + g.schritte.length, 0);
|
||||
const block = alle.blockNach.get(vorlage.block);
|
||||
|
||||
return (
|
||||
<section key={vorlage.id} className="karte stapel-eng">
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1rem', marginBottom: '0.15rem' }}>{vorlage.titel}</h3>
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
{schritte} Schritte in {vorlage.gruppen.length} Gruppen
|
||||
{block ? ` · ${block.titel}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: '0.93rem' }}>{vorlage.worum}</p>
|
||||
|
||||
{istAzubi ? (
|
||||
<StartFormular checklisteId={vorlage.id} mitTicket={vorlage.mit_ticket} heuteDatum={heute()} />
|
||||
) : (
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Quelle: <code>content/checklisten/{vorlage.id}.yaml</code>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{beteiligte.map((person) => {
|
||||
const laeufe = laeufeVon(person.id);
|
||||
const offene = laeufe.filter((l) => !l.abgeschlossenAm);
|
||||
const fertige = laeufe.filter((l) => l.abgeschlossenAm);
|
||||
|
||||
return (
|
||||
<section key={person.id} className="stapel" aria-labelledby={`laeufe-${person.id}`}>
|
||||
<h2 id={`laeufe-${person.id}`}>
|
||||
{istAzubi ? 'Deine Durchläufe' : `Durchläufe von ${person.name}`}
|
||||
</h2>
|
||||
|
||||
{laeufe.length === 0 ? (
|
||||
<p className="karte" style={{ marginBottom: 0 }}>
|
||||
Noch kein Durchlauf gestartet.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{offene.length > 0 ? (
|
||||
<div className="stapel-eng">
|
||||
<h3 style={{ fontSize: '0.95rem', marginBottom: 0 }}>Offen</h3>
|
||||
{offene.map((lauf) => (
|
||||
<Laufzeile key={lauf.id} laufId={lauf.id} titel={lauf.titel} datum={lauf.datum}
|
||||
ticket={lauf.ticket} kunde={lauf.kunde} abgeschlossenAm={null} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{fertige.length > 0 ? (
|
||||
<div className="stapel-eng">
|
||||
<h3 style={{ fontSize: '0.95rem', marginBottom: 0 }}>
|
||||
Abgeschlossen ({fertige.length})
|
||||
</h3>
|
||||
{fertige.map((lauf) => (
|
||||
<Laufzeile key={lauf.id} laufId={lauf.id} titel={lauf.titel} datum={lauf.datum}
|
||||
ticket={lauf.ticket} kunde={lauf.kunde} abgeschlossenAm={lauf.abgeschlossenAm} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Laufzeile({
|
||||
laufId,
|
||||
titel,
|
||||
datum,
|
||||
ticket,
|
||||
kunde,
|
||||
abgeschlossenAm,
|
||||
}: {
|
||||
laufId: string;
|
||||
titel: string;
|
||||
datum: string;
|
||||
ticket: string | null;
|
||||
kunde: string | null;
|
||||
abgeschlossenAm: string | null;
|
||||
}) {
|
||||
const stand = standVon(laufId);
|
||||
|
||||
return (
|
||||
<div className="laufzeile">
|
||||
<Link href={`/checklisten/lauf/${laufId}`} className="laufzeile-haupt">
|
||||
<span className="laufzeile-titel">{titel}</span>
|
||||
<span className="leise">
|
||||
{alsDatumText(datum)}
|
||||
{ticket ? ` · Ticket ${ticket}` : ''}
|
||||
{kunde ? ` · ${kunde}` : ''}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="laufzeile-stand">
|
||||
<span className="leise">
|
||||
{stand.erledigt} / {stand.gesamt}
|
||||
</span>
|
||||
<span className="balken" style={{ width: '90px' }}>
|
||||
<span style={{ width: `${stand.prozent}%` }} />
|
||||
</span>
|
||||
{abgeschlossenAm ? (
|
||||
<span className="marke-status" data-ton="gut" title={alsZeitpunktText(abgeschlossenAm)}>
|
||||
✓ fertig
|
||||
</span>
|
||||
) : (
|
||||
<span className="marke-status" data-ton="warnung">
|
||||
◐ offen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Auffangnetz für den angemeldeten Bereich.
|
||||
*
|
||||
* Verweigerte Berechtigungen landen **nicht** hier, sondern auf der Seite
|
||||
* „Nicht gefunden" – siehe erfordereSichtAufAzubi. Hier bleibt der echte
|
||||
* Störfall übrig. Der Fehlertext selbst wird im Produktionsbetrieb von Next.js
|
||||
* geschwärzt und steht nur im Serverprotokoll; deshalb gibt es hier keine
|
||||
* Fallunterscheidung nach Meldung, sondern nur das Kennzeichen zur Fehlersuche.
|
||||
*/
|
||||
export default function Fehlerseite({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error('[lernbar] Fehler in der Oberfläche:', error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="karte-grund stapel" style={{ maxWidth: '38rem', margin: '2rem auto', textAlign: 'center' }}>
|
||||
<p style={{ fontSize: '2.2rem', margin: 0 }} aria-hidden="true">
|
||||
⚠
|
||||
</p>
|
||||
|
||||
<h1 style={{ marginBottom: '0.2rem' }}>Da ist etwas schiefgegangen</h1>
|
||||
|
||||
<p className="leise">
|
||||
Der Vorgang ließ sich nicht abschließen. Der Fehler steht im Protokoll des Servers.
|
||||
</p>
|
||||
|
||||
<div className="reihe" style={{ justifyContent: 'center' }}>
|
||||
<Link href="/" className="knopf">
|
||||
Zur Startseite
|
||||
</Link>
|
||||
<button type="button" className="knopf knopf-zweit" onClick={reset}>
|
||||
Noch einmal versuchen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error.digest ? (
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
Kennzeichen für die Fehlersuche: <code>{error.digest}</code>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
|
||||
import { gespraechBestaetigen, gespraechSpeichern, type Rueckmeldung } from './aktionen';
|
||||
import { liegtWeitAuseinander, type Gespraechsinhalt } from '@/lib/feedback/inhalt';
|
||||
|
||||
/**
|
||||
* Der Bogen für das Gespräch.
|
||||
*
|
||||
* Jede Seite trägt ihre eigene Spalte ein: der Ausbilder seine Einschätzung
|
||||
* und die Freitexte, der Azubi seine Selbsteinschätzung. Wo beide deutlich
|
||||
* auseinanderliegen, wird das hervorgehoben – genau darüber lohnt das
|
||||
* Gespräch.
|
||||
*/
|
||||
export function Gespraechsformular({
|
||||
gespraechId,
|
||||
inhalt,
|
||||
leitfaden,
|
||||
istAzubi,
|
||||
gesperrt,
|
||||
datum,
|
||||
bereitsBestaetigt,
|
||||
}: {
|
||||
gespraechId: string;
|
||||
inhalt: Gespraechsinhalt;
|
||||
leitfaden: { abschnitt: string; fragen: string[] }[];
|
||||
istAzubi: boolean;
|
||||
gesperrt: boolean;
|
||||
datum: string;
|
||||
bereitsBestaetigt: boolean;
|
||||
}) {
|
||||
const [speicherstand, speichern] = useActionState<Rueckmeldung, FormData>(gespraechSpeichern, {});
|
||||
const [bestaetigungsstand, bestaetigen] = useActionState<Rueckmeldung, FormData>(gespraechBestaetigen, {});
|
||||
|
||||
const eigeneSpalte = istAzubi ? 'azubi' : 'ausbilder';
|
||||
|
||||
return (
|
||||
<form action={speichern} className="stapel">
|
||||
<input type="hidden" name="gespraechId" value={gespraechId} />
|
||||
|
||||
{/* Nach Abschluss steht das Datum schon groß im Kopf – ein zweites,
|
||||
gesperrtes Feld daneben sagt nichts dazu. */}
|
||||
{!istAzubi && !gesperrt ? (
|
||||
<div className="feld" style={{ maxWidth: '14rem' }}>
|
||||
<label htmlFor="datum">Datum des Gesprächs</label>
|
||||
<input id="datum" name="datum" type="date" defaultValue={datum} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* --- Bewertungsbogen --- */}
|
||||
|
||||
<section className="stapel-eng" aria-labelledby="bogen">
|
||||
<div>
|
||||
<h2 id="bogen" style={{ marginBottom: '0.15rem' }}>
|
||||
Bewertungsbogen
|
||||
</h2>
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
{inhalt.skala[0]?.wert} = {inhalt.skala[0]?.text} bis {inhalt.skala.at(-1)?.wert} ={' '}
|
||||
{inhalt.skala.at(-1)?.text}.{' '}
|
||||
{istAzubi
|
||||
? 'Du trägst deine Selbsteinschätzung ein – die Spalte des Ausbilders ist für dich gesperrt.'
|
||||
: 'Du trägst deine Einschätzung ein; die Selbsteinschätzung kommt vom Azubi.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="kriterienbogen">
|
||||
{inhalt.kriterien.map((kriterium) => {
|
||||
const bewertung = inhalt.bewertungen.find((b) => b.kriteriumId === kriterium.id);
|
||||
const eigener = eigeneSpalte === 'azubi' ? bewertung?.azubi : bewertung?.ausbilder;
|
||||
const anderer = eigeneSpalte === 'azubi' ? bewertung?.ausbilder : bewertung?.azubi;
|
||||
const weitAuseinander = liegtWeitAuseinander(bewertung);
|
||||
|
||||
return (
|
||||
<li key={kriterium.id} data-abweichung={weitAuseinander ? 'ja' : 'nein'}>
|
||||
<div className="kriterium-kopf">
|
||||
<strong>{kriterium.titel}</strong>
|
||||
<span className="leise">{kriterium.erlaeuterung}</span>
|
||||
</div>
|
||||
|
||||
<fieldset className="stufenwahl" disabled={gesperrt}>
|
||||
<legend className="nur-lesehilfe">
|
||||
{istAzubi ? 'Deine Selbsteinschätzung' : 'Deine Einschätzung'} für {kriterium.titel}
|
||||
</legend>
|
||||
|
||||
{inhalt.skala.map((stufe) => (
|
||||
<label key={stufe.wert} className="stufe" title={stufe.text}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`stufe-${kriterium.id}`}
|
||||
value={stufe.wert}
|
||||
defaultChecked={eigener === stufe.wert}
|
||||
/>
|
||||
<span>{stufe.wert}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<div className="kriterium-fuss">
|
||||
<span className="leise">
|
||||
{anderer === null || anderer === undefined
|
||||
? `${istAzubi ? 'Ausbilder' : 'Azubi'}: noch offen`
|
||||
: `${istAzubi ? 'Ausbilder' : 'Selbsteinschätzung'}: ${anderer} (${
|
||||
inhalt.skala.find((s) => s.wert === anderer)?.text ?? ''
|
||||
})`}
|
||||
</span>
|
||||
|
||||
{weitAuseinander ? (
|
||||
<span className="marke-status" data-ton="warnung">
|
||||
! unterschiedliche Sicht
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Wer nicht schreiben darf oder kann, sieht die Bemerkung als
|
||||
Text – und ein leeres Feld gar nicht. Ein abgeschlossenes
|
||||
Blatt für die Akte soll nicht voller Platzhalter stehen. */}
|
||||
{istAzubi || gesperrt ? (
|
||||
bewertung?.bemerkung ? (
|
||||
<p className="schritt-bemerkung">{bewertung.bemerkung}</p>
|
||||
) : null
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
name={`bemerkung-${kriterium.id}`}
|
||||
maxLength={500}
|
||||
defaultValue={bewertung?.bemerkung ?? ''}
|
||||
placeholder="Bemerkung – ein Beispiel sagt mehr als eine Zahl"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* --- Freitexte --- */}
|
||||
|
||||
{/* Nach Abschluss liest auch der Ausbilder nur noch – ein gesperrtes
|
||||
Eingabefeld sieht aus, als könnte man es doch noch benutzen. */}
|
||||
{istAzubi || gesperrt ? (
|
||||
<section className="stapel-eng" aria-labelledby="texte">
|
||||
<h2 id="texte">Stärken, Entwicklungsfelder, Ziele</h2>
|
||||
<Lesefeld titel="Stärken" text={inhalt.staerken} />
|
||||
<Lesefeld titel="Entwicklungsfelder" text={inhalt.entwicklungsfelder} />
|
||||
<Lesefeld titel="Vereinbarte Ziele" text={inhalt.ziele} />
|
||||
{inhalt.sonstiges ? <Lesefeld titel="Sonstiges" text={inhalt.sonstiges} /> : null}
|
||||
</section>
|
||||
) : (
|
||||
<section className="stapel-eng" aria-labelledby="texte">
|
||||
<h2 id="texte">Stärken, Entwicklungsfelder, Ziele</h2>
|
||||
|
||||
<div className="feld">
|
||||
<label htmlFor="staerken">Stärken</label>
|
||||
<textarea
|
||||
id="staerken"
|
||||
name="staerken"
|
||||
rows={3}
|
||||
maxLength={4000}
|
||||
defaultValue={inhalt.staerken}
|
||||
placeholder="Woran hast du gemerkt, dass er besser geworden ist? Nenne ein Beispiel."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label htmlFor="entwicklungsfelder">Entwicklungsfelder</label>
|
||||
<textarea
|
||||
id="entwicklungsfelder"
|
||||
name="entwicklungsfelder"
|
||||
rows={3}
|
||||
maxLength={4000}
|
||||
defaultValue={inhalt.entwicklungsfelder}
|
||||
placeholder="Was soll besser werden – und woran würde man es merken?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label htmlFor="ziele">Vereinbarte Ziele bis zum nächsten Gespräch</label>
|
||||
<textarea
|
||||
id="ziele"
|
||||
name="ziele"
|
||||
rows={3}
|
||||
maxLength={4000}
|
||||
defaultValue={inhalt.ziele}
|
||||
placeholder="So formuliert, dass beim nächsten Mal feststellbar ist, ob sie erreicht wurden."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label htmlFor="sonstiges">Sonstiges</label>
|
||||
<textarea
|
||||
id="sonstiges"
|
||||
name="sonstiges"
|
||||
rows={2}
|
||||
maxLength={4000}
|
||||
defaultValue={inhalt.sonstiges}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Leitfaden --- */}
|
||||
|
||||
<details className="karte formulierungshilfe" open={!istAzubi && !gesperrt}>
|
||||
<summary>
|
||||
<strong>Gesprächsleitfaden</strong>
|
||||
<span className="leise"> – Fragen für das Gespräch</span>
|
||||
</summary>
|
||||
|
||||
<div className="stapel" style={{ marginTop: '0.9rem' }}>
|
||||
{leitfaden.map((teil, stelle) => (
|
||||
<section key={teil.abschnitt} className="stapel-eng">
|
||||
<h3 style={{ fontSize: '0.95rem', marginBottom: '0.2rem' }}>{teil.abschnitt}</h3>
|
||||
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
|
||||
{teil.fragen.map((frage) => (
|
||||
<li key={frage}>{frage}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{istAzubi || gesperrt ? (
|
||||
inhalt.leitfadennotizen[stelle]?.notiz ? (
|
||||
<p className="notiz-anzeige">{inhalt.leitfadennotizen[stelle]!.notiz}</p>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
<label htmlFor={`leitfaden-${stelle}`} className="nur-lesehilfe">
|
||||
Notiz zu {teil.abschnitt}
|
||||
</label>
|
||||
<textarea
|
||||
id={`leitfaden-${stelle}`}
|
||||
name={`leitfaden-${stelle}`}
|
||||
rows={2}
|
||||
maxLength={4000}
|
||||
defaultValue={inhalt.leitfadennotizen[stelle]?.notiz ?? ''}
|
||||
placeholder="Notiz zum Gespräch"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<Meldung zustand={speicherstand} />
|
||||
<Meldung zustand={bestaetigungsstand} />
|
||||
|
||||
{!gesperrt ? (
|
||||
<div className="reihe nicht-drucken">
|
||||
<Absenden className="knopf knopf-zweit">Speichern</Absenden>
|
||||
|
||||
{!bereitsBestaetigt ? (
|
||||
<button type="submit" className="knopf knopf-akzent" formAction={bestaetigen}>
|
||||
{istAzubi ? 'Gespräch bestätigen' : 'Als Ausbilder bestätigen'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!gesperrt && !bereitsBestaetigt ? (
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
Bestätigen heißt: Du bist mit dem Inhalt einverstanden. Sobald beide bestätigt haben, ist das
|
||||
Gespräch unveränderlich und gehört in die Ausbildungsakte.
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Lesefeld({ titel, text }: { titel: string; text: string }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="wochentag-marke">{titel}</span>
|
||||
{text.trim() ? (
|
||||
<p className="notiz-anzeige">{text}</p>
|
||||
) : (
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Noch nichts eingetragen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { inhaltVon } from '@/lib/repos/feedback';
|
||||
import { istAbgeschlossen, zusammenfassung } from '@/lib/feedback/inhalt';
|
||||
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
|
||||
import { Gespraechsformular } from '../Gespraechsformular';
|
||||
import { bestaetigungZurueck, gespraechVerwerfen, gespraechZumLesen } from '../aktionen';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ gespraechId: string }> }): Promise<Metadata> {
|
||||
const { gespraechId } = await params;
|
||||
const gelesen = await gespraechZumLesen(gespraechId);
|
||||
return { title: gelesen ? `Feedbackgespräch ${alsDatumText(gelesen.gespraech.datum)}` : 'Feedbackgespräch' };
|
||||
}
|
||||
|
||||
export default async function GespraechsSeite({ params }: { params: Promise<{ gespraechId: string }> }) {
|
||||
const { gespraechId } = await params;
|
||||
|
||||
const gelesen = await gespraechZumLesen(gespraechId);
|
||||
if (!gelesen) notFound();
|
||||
|
||||
const { benutzer, gespraech, ausbildung } = gelesen;
|
||||
const vorlage = inhalte().feedback;
|
||||
const inhalt = inhaltVon(gespraech, vorlage);
|
||||
const summe = zusammenfassung(inhalt);
|
||||
|
||||
const istAzubi = benutzer.rolle === 'azubi';
|
||||
const azubi = benutzerNachId(ausbildung.azubiId);
|
||||
const ausbilder = benutzerNachId(ausbildung.ausbilderId);
|
||||
if (!azubi) notFound();
|
||||
|
||||
const fertig = istAbgeschlossen({
|
||||
bestaetigtAzubi: gespraech.bestaetigtAzubi === 1,
|
||||
bestaetigtAusbilder: gespraech.bestaetigtAusbilder === 1,
|
||||
});
|
||||
const bereitsBestaetigt = istAzubi ? gespraech.bestaetigtAzubi === 1 : gespraech.bestaetigtAusbilder === 1;
|
||||
|
||||
return (
|
||||
<article className="stapel">
|
||||
<nav className="leise nicht-drucken" aria-label="Wo du bist">
|
||||
<Link href="/feedback">Feedbackgespräche</Link>
|
||||
{!istAzubi ? ` › ${azubi.name}` : ''}
|
||||
</nav>
|
||||
|
||||
<header className="stapel-eng">
|
||||
<div className="reihe" style={{ gap: '0.6rem' }}>
|
||||
<span className="gespraech-datum gespraech-datum-gross">{alsDatumText(gespraech.datum)}</span>
|
||||
{fertig ? (
|
||||
<span className="marke-status" data-ton="gut">
|
||||
🔒 abgeschlossen und unveränderlich
|
||||
</span>
|
||||
) : (
|
||||
<span className="marke-status" data-ton="warnung">
|
||||
◐ in Arbeit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 style={{ marginBottom: '0.2rem' }}>Feedbackgespräch</h1>
|
||||
<p className="leise">
|
||||
{azubi.name}
|
||||
{ausbilder ? ` und ${ausbilder.name}` : ''} · {ausbildung.beruf}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* --- Bestätigungsstand --- */}
|
||||
|
||||
<section className="karte stapel-eng" aria-labelledby="bestaetigung">
|
||||
<h2 id="bestaetigung" style={{ margin: 0, fontSize: '1rem' }}>
|
||||
Bestätigung
|
||||
</h2>
|
||||
|
||||
<ul className="offen-liste">
|
||||
<li data-erledigt={gespraech.bestaetigtAusbilder === 1 ? 'ja' : 'nein'}>
|
||||
<span>{ausbilder?.name ?? 'Ausbilder'}</span>
|
||||
<span className="leise">
|
||||
{gespraech.bestaetigtAusbilder === 1
|
||||
? `bestätigt ${alsZeitpunktText(gespraech.bestaetigtAusbilderAm)}`
|
||||
: 'noch offen'}
|
||||
</span>
|
||||
</li>
|
||||
<li data-erledigt={gespraech.bestaetigtAzubi === 1 ? 'ja' : 'nein'}>
|
||||
<span>{azubi.name}</span>
|
||||
<span className="leise">
|
||||
{gespraech.bestaetigtAzubi === 1
|
||||
? `bestätigt ${alsZeitpunktText(gespraech.bestaetigtAzubiAm)}`
|
||||
: 'noch offen'}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{summe.abweichungen.length > 0 ? (
|
||||
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
|
||||
<strong>Unterschiedliche Sicht bei:</strong> {summe.abweichungen.join(', ')}. Genau darüber lohnt
|
||||
das Gespräch.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{bereitsBestaetigt && !fertig ? (
|
||||
<form action={bestaetigungZurueck} className="nicht-drucken">
|
||||
<input type="hidden" name="gespraechId" value={gespraech.id} />
|
||||
<button type="submit" className="knopf knopf-still">
|
||||
Bestätigung zurücknehmen
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<Gespraechsformular
|
||||
gespraechId={gespraech.id}
|
||||
inhalt={inhalt}
|
||||
leitfaden={vorlage.leitfaden}
|
||||
istAzubi={istAzubi}
|
||||
gesperrt={fertig}
|
||||
datum={gespraech.datum}
|
||||
bereitsBestaetigt={bereitsBestaetigt}
|
||||
/>
|
||||
|
||||
<div className="reihe nicht-drucken">
|
||||
<a href={`/feedback/${gespraech.id}/pdf`} className="knopf knopf-zweit" target="_blank" rel="noreferrer">
|
||||
Als PDF für die Akte
|
||||
</a>
|
||||
|
||||
{!istAzubi && gespraech.bestaetigtAzubi === 0 && gespraech.bestaetigtAusbilder === 0 ? (
|
||||
<form action={gespraechVerwerfen}>
|
||||
<input type="hidden" name="gespraechId" value={gespraech.id} />
|
||||
<button type="submit" className="knopf knopf-still">
|
||||
Verwerfen
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{vorlage.hinweis ? (
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
{vorlage.hinweis}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { inhaltVon } from '@/lib/repos/feedback';
|
||||
import { feedbackPdf } from '@/lib/pdf/feedback';
|
||||
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
|
||||
import { gespraechZumLesen } from '../../aktionen';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** Das Feedbackgespräch als PDF. Rechteprüfung über `gespraechZumLesen`. */
|
||||
export async function GET(_anfrage: Request, kontext: { params: Promise<{ gespraechId: string }> }) {
|
||||
const { gespraechId } = await kontext.params;
|
||||
|
||||
const gelesen = await gespraechZumLesen(gespraechId);
|
||||
if (!gelesen) return new Response('Nicht gefunden.', { status: 404 });
|
||||
|
||||
const { gespraech, ausbildung } = gelesen;
|
||||
const azubi = benutzerNachId(ausbildung.azubiId);
|
||||
const ausbilder = benutzerNachId(ausbildung.ausbilderId);
|
||||
|
||||
const pdf = await feedbackPdf({
|
||||
datum: alsDatumText(gespraech.datum),
|
||||
azubi: azubi?.name ?? 'unbekannt',
|
||||
ausbilder: ausbilder?.name ?? '',
|
||||
beruf: ausbildung.beruf,
|
||||
inhalt: inhaltVon(gespraech, inhalte().feedback),
|
||||
bestaetigtAzubiAm: gespraech.bestaetigtAzubiAm ? alsZeitpunktText(gespraech.bestaetigtAzubiAm) : null,
|
||||
bestaetigtAusbilderAm: gespraech.bestaetigtAusbilderAm
|
||||
? alsZeitpunktText(gespraech.bestaetigtAusbilderAm)
|
||||
: null,
|
||||
});
|
||||
|
||||
const dateiname = `Feedbackgespraech_${gespraech.datum}.pdf`;
|
||||
|
||||
return new Response(new Uint8Array(pdf), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Length': String(pdf.byteLength),
|
||||
'Content-Disposition': `inline; filename="${dateiname}"`,
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { erfordereBenutzer, erfordereBewertungsrecht } from '@/lib/auth/rechte';
|
||||
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
|
||||
import { ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import {
|
||||
anlegen,
|
||||
bestaetigen,
|
||||
bestaetigungZuruecknehmen,
|
||||
gespraechNachId,
|
||||
inhaltSpeichern,
|
||||
inhaltVon,
|
||||
loeschen,
|
||||
} from '@/lib/repos/feedback';
|
||||
import { istAbgeschlossen, type Gespraechsinhalt } from '@/lib/feedback/inhalt';
|
||||
|
||||
export type Rueckmeldung = { fehler?: string; erfolg?: string };
|
||||
|
||||
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Holt ein Gespräch samt Rechteprüfung.
|
||||
*
|
||||
* Beide Seiten dürfen lesen und – solange nicht beide bestätigt haben –
|
||||
* schreiben. Das Gespräch gehört ihnen gemeinsam; eine Selbsteinschätzung,
|
||||
* die der Ausbilder eintragen könnte, wäre keine.
|
||||
*/
|
||||
export async function gespraechZumLesen(id: string) {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const gespraech = gespraechNachId(id);
|
||||
if (!gespraech) return null;
|
||||
|
||||
const ausbildung = ausbildungNachId(gespraech.ausbildungId);
|
||||
if (!ausbildung) return null;
|
||||
|
||||
if (!darfAzubiDatenSehen(benutzer, ausbildung.azubiId, ausbildung)) return null;
|
||||
|
||||
return { benutzer, gespraech, ausbildung };
|
||||
}
|
||||
|
||||
function aktualisiere(id?: string) {
|
||||
revalidatePath('/feedback');
|
||||
if (id) revalidatePath(`/feedback/${id}`);
|
||||
}
|
||||
|
||||
export async function gespraechAnlegen(formular: FormData): Promise<void> {
|
||||
const ausbilder = await erfordereBenutzer();
|
||||
const azubiId = String(formular.get('azubiId') ?? '');
|
||||
erfordereBewertungsrecht(ausbilder, azubiId);
|
||||
|
||||
const ausbildung = ausbildungNachAzubi(azubiId);
|
||||
if (!ausbildung) return;
|
||||
|
||||
const datum = String(formular.get('datum') ?? '').trim();
|
||||
const gespraech = anlegen(
|
||||
ausbildung.id,
|
||||
ausbilder.id,
|
||||
inhalte().feedback,
|
||||
datumMuster.test(datum) ? datum : undefined,
|
||||
);
|
||||
|
||||
aktualisiere(gespraech.id);
|
||||
redirect(`/feedback/${gespraech.id}`);
|
||||
}
|
||||
|
||||
export async function gespraechSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const id = String(formular.get('gespraechId') ?? '');
|
||||
const gelesen = await gespraechZumLesen(id);
|
||||
if (!gelesen) return { fehler: 'Dieses Gespräch gibt es nicht.' };
|
||||
|
||||
const { benutzer, gespraech } = gelesen;
|
||||
|
||||
if (istAbgeschlossen({
|
||||
bestaetigtAzubi: gespraech.bestaetigtAzubi === 1,
|
||||
bestaetigtAusbilder: gespraech.bestaetigtAusbilder === 1,
|
||||
})) {
|
||||
return { fehler: 'Das Gespräch ist von beiden Seiten bestätigt und damit unveränderlich.' };
|
||||
}
|
||||
|
||||
const vorlage = inhalte().feedback;
|
||||
const bestand = inhaltVon(gespraech, vorlage);
|
||||
const istAzubi = benutzer.rolle === 'azubi';
|
||||
|
||||
// Jede Seite trägt nur ihre eigene Spalte ein.
|
||||
const bewertungen: Gespraechsinhalt['bewertungen'] = bestand.bewertungen.map((bewertung) => {
|
||||
const roh = String(formular.get(`stufe-${bewertung.kriteriumId}`) ?? '').trim();
|
||||
const gelesen = roh === '' ? null : Number(roh);
|
||||
const gueltig = gelesen !== null && bestand.skala.some((s) => s.wert === gelesen) ? gelesen : null;
|
||||
|
||||
return {
|
||||
...bewertung,
|
||||
ausbilder: istAzubi ? bewertung.ausbilder : gueltig,
|
||||
azubi: istAzubi ? gueltig : bewertung.azubi,
|
||||
// Die Bemerkung je Kriterium schreibt der Ausbilder.
|
||||
bemerkung: istAzubi
|
||||
? bewertung.bemerkung
|
||||
: String(formular.get(`bemerkung-${bewertung.kriteriumId}`) ?? '').slice(0, 500),
|
||||
};
|
||||
});
|
||||
|
||||
// Die Freitexte schreibt der Ausbilder; der Azubi liest sie.
|
||||
const neu: Gespraechsinhalt = istAzubi
|
||||
? { ...bestand, bewertungen }
|
||||
: {
|
||||
...bestand,
|
||||
bewertungen,
|
||||
leitfadennotizen: bestand.leitfadennotizen.map((notiz, stelle) => ({
|
||||
...notiz,
|
||||
notiz: String(formular.get(`leitfaden-${stelle}`) ?? '').slice(0, 4000),
|
||||
})),
|
||||
staerken: String(formular.get('staerken') ?? '').slice(0, 4000),
|
||||
entwicklungsfelder: String(formular.get('entwicklungsfelder') ?? '').slice(0, 4000),
|
||||
ziele: String(formular.get('ziele') ?? '').slice(0, 4000),
|
||||
sonstiges: String(formular.get('sonstiges') ?? '').slice(0, 4000),
|
||||
};
|
||||
|
||||
const datum = String(formular.get('datum') ?? '').trim();
|
||||
inhaltSpeichern(id, neu, !istAzubi && datumMuster.test(datum) ? datum : undefined);
|
||||
|
||||
aktualisiere(id);
|
||||
return { erfolg: istAzubi ? 'Deine Selbsteinschätzung ist gespeichert.' : 'Gespeichert.' };
|
||||
}
|
||||
|
||||
export async function gespraechBestaetigen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const id = String(formular.get('gespraechId') ?? '');
|
||||
const gelesen = await gespraechZumLesen(id);
|
||||
if (!gelesen) return { fehler: 'Dieses Gespräch gibt es nicht.' };
|
||||
|
||||
const { benutzer, gespraech } = gelesen;
|
||||
const rolle = benutzer.rolle === 'azubi' ? 'azubi' : 'ausbilder';
|
||||
|
||||
const schon = rolle === 'azubi' ? gespraech.bestaetigtAzubi === 1 : gespraech.bestaetigtAusbilder === 1;
|
||||
if (schon) return { fehler: 'Du hast bereits bestätigt.' };
|
||||
|
||||
bestaetigen(id, rolle);
|
||||
aktualisiere(id);
|
||||
|
||||
const andereHatSchon = rolle === 'azubi' ? gespraech.bestaetigtAusbilder === 1 : gespraech.bestaetigtAzubi === 1;
|
||||
|
||||
return {
|
||||
erfolg: andereHatSchon
|
||||
? 'Bestätigt. Beide Seiten haben zugestimmt – das Gespräch ist jetzt unveränderlich.'
|
||||
: 'Bestätigt. Sobald die andere Seite ebenfalls bestätigt, ist das Gespräch unveränderlich.',
|
||||
};
|
||||
}
|
||||
|
||||
export async function bestaetigungZurueck(formular: FormData): Promise<void> {
|
||||
const id = String(formular.get('gespraechId') ?? '');
|
||||
const gelesen = await gespraechZumLesen(id);
|
||||
if (!gelesen) return;
|
||||
|
||||
const { benutzer, gespraech } = gelesen;
|
||||
const rolle = benutzer.rolle === 'azubi' ? 'azubi' : 'ausbilder';
|
||||
|
||||
// Hat die andere Seite schon bestätigt, ist das Gespräch abgeschlossen.
|
||||
const andereHatSchon = rolle === 'azubi' ? gespraech.bestaetigtAusbilder === 1 : gespraech.bestaetigtAzubi === 1;
|
||||
if (andereHatSchon) return;
|
||||
|
||||
bestaetigungZuruecknehmen(id, rolle);
|
||||
aktualisiere(id);
|
||||
}
|
||||
|
||||
export async function gespraechVerwerfen(formular: FormData): Promise<void> {
|
||||
const id = String(formular.get('gespraechId') ?? '');
|
||||
const gelesen = await gespraechZumLesen(id);
|
||||
if (!gelesen) return;
|
||||
|
||||
const { benutzer, gespraech } = gelesen;
|
||||
if (benutzer.rolle !== 'ausbilder') return;
|
||||
|
||||
// Bestätigtes bleibt. Verworfen wird nur, was noch niemand unterschrieben hat.
|
||||
if (gespraech.bestaetigtAzubi === 1 || gespraech.bestaetigtAusbilder === 1) return;
|
||||
|
||||
loeschen(id);
|
||||
revalidatePath('/feedback');
|
||||
redirect('/feedback');
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { gespraecheVon, inhaltVon } from '@/lib/repos/feedback';
|
||||
import { istAbgeschlossen, zusammenfassung } from '@/lib/feedback/inhalt';
|
||||
import { alsDatumText, heute, montagDerWoche } from '@/lib/zeit';
|
||||
import { gespraechAnlegen } from './aktionen';
|
||||
|
||||
export const metadata: Metadata = { title: 'Feedbackgespräche' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function FeedbackSeite() {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const vorlage = inhalte().feedback;
|
||||
|
||||
const beteiligte =
|
||||
benutzer.rolle === 'ausbilder'
|
||||
? ausbildungenVonAusbilder(benutzer.id).map(({ azubi, ausbildung }) => ({ azubi, ausbildung }))
|
||||
: (() => {
|
||||
const ausbildung = ausbildungNachAzubi(benutzer.id);
|
||||
return ausbildung ? [{ azubi: benutzer, ausbildung }] : [];
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Feedbackgespräche</h1>
|
||||
<p className="leise">
|
||||
Monatlich, mit Leitfaden und Bewertungsbogen. Von beiden Seiten bestätigt und danach unveränderlich –
|
||||
für die Ausbildungsakte.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{beteiligte.length === 0 ? (
|
||||
<p className="hinweis hinweis-warnung">
|
||||
{benutzer.rolle === 'ausbilder' ? (
|
||||
<>
|
||||
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
|
||||
</>
|
||||
) : (
|
||||
'Zu deinem Konto ist noch keine Ausbildung hinterlegt.'
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
beteiligte.map(({ azubi, ausbildung }) => {
|
||||
const gespraeche = gespraecheVon(ausbildung.id);
|
||||
const letztes = gespraeche[0];
|
||||
const faellig =
|
||||
!letztes || montagDerWoche(letztes.datum) < montagDerWoche(vorMonaten(heute(), 1));
|
||||
|
||||
return (
|
||||
<section key={ausbildung.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
|
||||
{benutzer.rolle === 'ausbilder' ? azubi.name : 'Deine Gespräche'}
|
||||
</h2>
|
||||
|
||||
{benutzer.rolle === 'ausbilder' ? (
|
||||
<form action={gespraechAnlegen}>
|
||||
<input type="hidden" name="azubiId" value={azubi.id} />
|
||||
<input type="hidden" name="datum" value={heute()} />
|
||||
<button type="submit" className={`knopf ${faellig ? 'knopf-akzent' : 'knopf-zweit'}`}>
|
||||
Neues Gespräch anlegen
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{faellig && benutzer.rolle === 'ausbilder' ? (
|
||||
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
|
||||
{letztes
|
||||
? `Das letzte Gespräch war am ${alsDatumText(letztes.datum)} – das ist über einen Monat her.`
|
||||
: 'Es gab noch kein Feedbackgespräch.'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{gespraeche.length === 0 ? (
|
||||
<p className="karte" style={{ marginBottom: 0 }}>
|
||||
Noch kein Gespräch geführt.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="gespraechsliste">
|
||||
{gespraeche.map((gespraech) => {
|
||||
const inhalt = inhaltVon(gespraech, vorlage);
|
||||
const summe = zusammenfassung(inhalt);
|
||||
const fertig = istAbgeschlossen({
|
||||
bestaetigtAzubi: gespraech.bestaetigtAzubi === 1,
|
||||
bestaetigtAusbilder: gespraech.bestaetigtAusbilder === 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<li key={gespraech.id}>
|
||||
<Link href={`/feedback/${gespraech.id}`} className="gespraechszeile">
|
||||
<span className="gespraech-datum">{alsDatumText(gespraech.datum)}</span>
|
||||
|
||||
<span className="gespraech-inhalt">
|
||||
{summe.bewertet} von {summe.gesamt} Kriterien bewertet
|
||||
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
|
||||
{summe.schnittAusbilder !== null
|
||||
? `Ausbilder ⌀ ${zahl(summe.schnittAusbilder)}`
|
||||
: 'noch keine Einschätzung'}
|
||||
{summe.schnittAzubi !== null ? ` · Selbsteinschätzung ⌀ ${zahl(summe.schnittAzubi)}` : ''}
|
||||
{summe.abweichungen.length > 0
|
||||
? ` · ${summe.abweichungen.length} unterschiedliche Sicht${summe.abweichungen.length === 1 ? '' : 'en'}`
|
||||
: ''}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="reihe" style={{ gap: '0.35rem' }}>
|
||||
<span
|
||||
className="marke-status"
|
||||
data-ton={gespraech.bestaetigtAusbilder === 1 ? 'gut' : 'neutral'}
|
||||
>
|
||||
{gespraech.bestaetigtAusbilder === 1 ? '✓' : '○'} Ausbilder
|
||||
</span>
|
||||
<span
|
||||
className="marke-status"
|
||||
data-ton={gespraech.bestaetigtAzubi === 1 ? 'gut' : 'neutral'}
|
||||
>
|
||||
{gespraech.bestaetigtAzubi === 1 ? '✓' : '○'} Azubi
|
||||
</span>
|
||||
{fertig ? (
|
||||
<span className="marke-status" data-ton="gut">
|
||||
🔒 abgeschlossen
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function vorMonaten(datum: string, monate: number): string {
|
||||
const d = new Date(`${datum}T12:00:00Z`);
|
||||
d.setUTCMonth(d.getUTCMonth() - monate);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function zahl(wert: number): string {
|
||||
return wert.toString().replace('.', ',');
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { inhalte } from '@/lib/inhalte';
|
||||
import { glossarAnker } from '@/lib/inhalte/glossarverweise';
|
||||
import { normalisiere } from '@/lib/inhalte/laden';
|
||||
import { GlossarSuche } from './GlossarSuche';
|
||||
import { Druckknopf } from '@/komponenten/Druckknopf';
|
||||
|
||||
export const metadata: Metadata = { title: 'Glossar' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -67,6 +68,10 @@ export default async function GlossarSeite({ searchParams }: { searchParams: Pro
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div className="reihe nicht-drucken">
|
||||
<Druckknopf beschriftung="Glossar drucken" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise';
|
||||
import { gelesenAm } from '@/lib/repos/fortschritt';
|
||||
import { alsZeitpunktText } from '@/lib/zeit';
|
||||
import { gelesenUmschalten } from './aktionen';
|
||||
import { Druckknopf } from '@/komponenten/Druckknopf';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -159,6 +160,10 @@ export default async function HandoutSeite({ params }: { params: Promise<{ id: s
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<div className="reihe nicht-drucken">
|
||||
<Druckknopf beschriftung="Handout drucken" />
|
||||
</div>
|
||||
|
||||
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Weitere Handouts">
|
||||
{vorher ? (
|
||||
<Link href={`/handouts/${vorher.id}`} className="knopf knopf-still">
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { bewerte } from '@/lib/repos/karten';
|
||||
import { bewertungen, fachNamen, type Bewertung } from '@/lib/lernen/sm2';
|
||||
import { alsDatumText, heute } from '@/lib/zeit';
|
||||
|
||||
export type Bewertungsergebnis = {
|
||||
fehler?: string;
|
||||
/** Wann die Karte wieder drankommt, in Worten. */
|
||||
wiederAm?: string;
|
||||
fach?: number;
|
||||
fachName?: string;
|
||||
/** Kommt die Karte noch in dieser Sitzung wieder? */
|
||||
nochHeute?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Eine Selbstbewertung entgegennehmen.
|
||||
*
|
||||
* Nur der Azubi lernt – der Ausbilder sieht die Karten, bewertet sie aber nicht
|
||||
* für ihn. Sonst stünde im Lernstand etwas, das der Azubi nie gekonnt hat.
|
||||
*/
|
||||
export async function karteBewerten(karteId: string, bewertung: string): Promise<Bewertungsergebnis> {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
if (benutzer.rolle !== 'azubi') {
|
||||
return { fehler: 'Nur der Azubi führt seinen eigenen Lernstand.' };
|
||||
}
|
||||
|
||||
if (!inhalte().karteNach.has(karteId)) {
|
||||
return { fehler: 'Diese Karte gibt es nicht.' };
|
||||
}
|
||||
if (!bewertungen.includes(bewertung as Bewertung)) {
|
||||
return { fehler: 'Unbekannte Bewertung.' };
|
||||
}
|
||||
|
||||
const heuteDatum = heute();
|
||||
const ergebnis = bewerte(benutzer.id, karteId, bewertung as Bewertung, heuteDatum);
|
||||
|
||||
revalidatePath('/karten');
|
||||
revalidatePath('/');
|
||||
|
||||
return {
|
||||
wiederAm:
|
||||
ergebnis.faelligAm <= heuteDatum
|
||||
? 'heute noch einmal'
|
||||
: ergebnis.stand.abstandTage === 1
|
||||
? 'morgen'
|
||||
: `in ${ergebnis.stand.abstandTage} Tagen (${alsDatumText(ergebnis.faelligAm)})`,
|
||||
fach: ergebnis.fach,
|
||||
fachName: fachNamen[ergebnis.fach],
|
||||
nochHeute: ergebnis.faelligAm <= heuteDatum,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { karteBewerten, type Bewertungsergebnis } from '../aktionen';
|
||||
import { einreihen } from '@/lib/offline/warteschlange';
|
||||
|
||||
export type Lernkarte = {
|
||||
id: string;
|
||||
frage: string;
|
||||
antwort: string;
|
||||
handoutId: string;
|
||||
handoutTitel: string;
|
||||
blockTitel: string;
|
||||
fach: number;
|
||||
fachName: string;
|
||||
};
|
||||
|
||||
type Bewertung = 'sitzt' | 'unsicher' | 'nochmal';
|
||||
|
||||
const knoepfe: { bewertung: Bewertung; text: string; taste: string; klasse: string; erklaerung: string }[] = [
|
||||
{ bewertung: 'nochmal', text: 'Nochmal', taste: '1', klasse: 'bewerten-nochmal', erklaerung: 'kommt heute wieder' },
|
||||
{ bewertung: 'unsicher', text: 'Unsicher', taste: '2', klasse: 'bewerten-unsicher', erklaerung: 'kommt morgen' },
|
||||
{ bewertung: 'sitzt', text: 'Sitzt', taste: '3', klasse: 'bewerten-sitzt', erklaerung: 'kommt später' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Der Lernmodus.
|
||||
*
|
||||
* Gebaut für das Handy: große Flächen, Antippen zum Umdrehen, Wischen als
|
||||
* Abkürzung, kein Überfahren mit der Maus. Am Rechner tun es zusätzlich die
|
||||
* Tasten 1, 2, 3 und die Leertaste.
|
||||
*/
|
||||
export function Lernmodus({
|
||||
karten,
|
||||
block,
|
||||
benutzerId,
|
||||
}: {
|
||||
karten: Lernkarte[];
|
||||
block: string | null;
|
||||
benutzerId: string;
|
||||
}) {
|
||||
const [stapel, setzeStapel] = useState<Lernkarte[]>(karten);
|
||||
const [stelle, setzeStelle] = useState(0);
|
||||
const [umgedreht, setzeUmgedreht] = useState(false);
|
||||
const [laeuft, setzeLaeuft] = useState(false);
|
||||
const [rueckmeldung, setzeRueckmeldung] = useState<Bewertungsergebnis | null>(null);
|
||||
const [erledigt, setzeErledigt] = useState(0);
|
||||
const [zaehler, setzeZaehler] = useState<Record<Bewertung, number>>({ sitzt: 0, unsicher: 0, nochmal: 0 });
|
||||
|
||||
const beruehrungStart = useRef<{ x: number; y: number } | null>(null);
|
||||
const karte = stapel[stelle];
|
||||
const fertig = !karte;
|
||||
|
||||
const bewerten = useCallback(
|
||||
async (bewertung: Bewertung) => {
|
||||
if (!karte || laeuft) return;
|
||||
setzeLaeuft(true);
|
||||
|
||||
const ergebnis = await bewertungAbsetzen(benutzerId, karte.id, bewertung);
|
||||
setzeRueckmeldung(ergebnis);
|
||||
setzeZaehler((z) => ({ ...z, [bewertung]: z[bewertung] + 1 }));
|
||||
setzeErledigt((e) => e + 1);
|
||||
|
||||
// „Nochmal" heißt: heute wieder. Also hinten anhängen, statt sie
|
||||
// erst am nächsten Tag zu zeigen – sonst ist die Bewertung folgenlos.
|
||||
if (bewertung === 'nochmal') {
|
||||
setzeStapel((s) => [...s, karte]);
|
||||
}
|
||||
|
||||
setzeUmgedreht(false);
|
||||
setzeStelle((s) => s + 1);
|
||||
setzeLaeuft(false);
|
||||
},
|
||||
[karte, laeuft, benutzerId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function beiTaste(ereignis: KeyboardEvent) {
|
||||
if (ereignis.target instanceof HTMLElement && ['INPUT', 'TEXTAREA'].includes(ereignis.target.tagName)) return;
|
||||
|
||||
if (ereignis.key === ' ' || ereignis.key === 'Enter') {
|
||||
ereignis.preventDefault();
|
||||
if (!umgedreht) setzeUmgedreht(true);
|
||||
return;
|
||||
}
|
||||
if (!umgedreht) return;
|
||||
|
||||
const treffer = knoepfe.find((k) => k.taste === ereignis.key);
|
||||
if (treffer) {
|
||||
ereignis.preventDefault();
|
||||
void bewerten(treffer.bewertung);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', beiTaste);
|
||||
return () => window.removeEventListener('keydown', beiTaste);
|
||||
}, [umgedreht, bewerten]);
|
||||
|
||||
function beiBeruehrungStart(ereignis: React.TouchEvent) {
|
||||
const punkt = ereignis.touches[0];
|
||||
if (punkt) beruehrungStart.current = { x: punkt.clientX, y: punkt.clientY };
|
||||
}
|
||||
|
||||
function beiBeruehrungEnde(ereignis: React.TouchEvent) {
|
||||
const start = beruehrungStart.current;
|
||||
const punkt = ereignis.changedTouches[0];
|
||||
beruehrungStart.current = null;
|
||||
if (!start || !punkt) return;
|
||||
|
||||
const dx = punkt.clientX - start.x;
|
||||
const dy = punkt.clientY - start.y;
|
||||
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy)) return; // kein eindeutiges Wischen
|
||||
|
||||
if (!umgedreht) {
|
||||
setzeUmgedreht(true);
|
||||
return;
|
||||
}
|
||||
void bewerten(dx > 0 ? 'sitzt' : 'nochmal');
|
||||
}
|
||||
|
||||
if (fertig) {
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div className="karte-grund stapel" style={{ textAlign: 'center', padding: '2rem 1.25rem' }}>
|
||||
<p style={{ fontSize: '2.5rem', margin: 0 }} aria-hidden="true">
|
||||
✓
|
||||
</p>
|
||||
<h1 style={{ marginBottom: '0.2rem' }}>Stapel durch</h1>
|
||||
<p className="leise">
|
||||
{erledigt} {erledigt === 1 ? 'Karte' : 'Karten'} bewertet.
|
||||
</p>
|
||||
|
||||
<ul className="bewertung-bilanz">
|
||||
<li>
|
||||
<strong>{zaehler.sitzt}</strong> <span>sitzt</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>{zaehler.unsicher}</strong> <span>unsicher</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>{zaehler.nochmal}</strong> <span>nochmal</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
Morgen wartet der nächste Stapel. Wer täglich zehn Minuten macht, hat vor der Prüfung nichts
|
||||
nachzuholen.
|
||||
</p>
|
||||
|
||||
<div className="reihe" style={{ justifyContent: 'center' }}>
|
||||
<Link href="/karten" className="knopf">
|
||||
Zurück zur Übersicht
|
||||
</Link>
|
||||
<Link href="/" className="knopf knopf-zweit">
|
||||
Zur Woche
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const uebrig = stapel.length - stelle;
|
||||
|
||||
return (
|
||||
<div className="lernmodus">
|
||||
<div className="lernmodus-kopf nicht-drucken">
|
||||
<Link href="/karten" className="knopf knopf-still">
|
||||
← Abbrechen
|
||||
</Link>
|
||||
<span className="leise">
|
||||
{block ? `${karte.blockTitel} · ` : ''}
|
||||
noch {uebrig} {uebrig === 1 ? 'Karte' : 'Karten'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="balken"
|
||||
role="progressbar"
|
||||
aria-valuenow={erledigt}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={erledigt + uebrig}
|
||||
aria-label={`${erledigt} von ${erledigt + uebrig} Karten bewertet`}
|
||||
>
|
||||
<span style={{ width: `${Math.round((erledigt / (erledigt + uebrig)) * 100)}%` }} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`lernkarte${umgedreht ? ' lernkarte-offen' : ''}`}
|
||||
onClick={() => !umgedreht && setzeUmgedreht(true)}
|
||||
onTouchStart={beiBeruehrungStart}
|
||||
onTouchEnd={beiBeruehrungEnde}
|
||||
aria-label={umgedreht ? 'Antwort' : 'Frage – zum Umdrehen antippen'}
|
||||
aria-expanded={umgedreht}
|
||||
>
|
||||
<span className="lernkarte-herkunft">
|
||||
<span className="handout-kuerzel">{karte.handoutId}</span>
|
||||
<span className="leise">{karte.handoutTitel}</span>
|
||||
</span>
|
||||
|
||||
<span className="lernkarte-frage">{karte.frage}</span>
|
||||
|
||||
{umgedreht ? (
|
||||
<>
|
||||
<span className="lernkarte-trenner" aria-hidden="true" />
|
||||
<span className="lernkarte-antwort">{karte.antwort}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="lernkarte-hinweis">
|
||||
Antworte erst laut oder im Kopf.
|
||||
<br />
|
||||
<strong>Antippen zum Umdrehen</strong>
|
||||
<span className="nur-am-rechner"> · oder Leertaste</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{umgedreht ? (
|
||||
<div className="bewerten">
|
||||
{knoepfe.map((knopf) => (
|
||||
<button
|
||||
key={knopf.bewertung}
|
||||
type="button"
|
||||
className={`bewerten-knopf ${knopf.klasse}`}
|
||||
onClick={() => void bewerten(knopf.bewertung)}
|
||||
disabled={laeuft}
|
||||
>
|
||||
<span className="bewerten-text">{knopf.text}</span>
|
||||
<span className="bewerten-erklaerung">{knopf.erklaerung}</span>
|
||||
<span className="bewerten-taste nur-am-rechner" aria-hidden="true">
|
||||
{knopf.taste}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="leise lernmodus-fuss">
|
||||
Wischen: nach rechts für „sitzt“, nach links für „nochmal“.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p aria-live="polite" className="lernmodus-rueckmeldung">
|
||||
{rueckmeldung?.wiederAm ? `Zuletzt: kommt ${rueckmeldung.wiederAm}.` : ''}
|
||||
{rueckmeldung?.fehler ? rueckmeldung.fehler : ''}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Bewertung absetzen – und wenn kein Netz da ist, sie merken.
|
||||
*
|
||||
* Ohne Netz scheitert die Serveraktion mit einem Netzfehler. Dann geht die
|
||||
* Bewertung in die Warteschlange und wird nachgereicht, sobald die Verbindung
|
||||
* wieder steht. Wann die Karte dann wiederkommt, sagt der Server; hier wird
|
||||
* bewusst nichts ausgerechnet, was später anders ausfallen könnte.
|
||||
*/
|
||||
async function bewertungAbsetzen(
|
||||
benutzerId: string,
|
||||
karteId: string,
|
||||
bewertung: Bewertung,
|
||||
): Promise<Bewertungsergebnis> {
|
||||
try {
|
||||
return await karteBewerten(karteId, bewertung);
|
||||
} catch {
|
||||
einreihen(benutzerId, { karteId, bewertung, erfasstAm: heutigesDatum() });
|
||||
window.dispatchEvent(new Event('lernbar-warteschlange'));
|
||||
return { wiederAm: 'gemerkt – geht ab, sobald wieder Netz da ist' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Der heutige Tag nach der Uhr des Geräts, als `JJJJ-MM-TT`. */
|
||||
function heutigesDatum(): string {
|
||||
const jetzt = new Date();
|
||||
const zweistellig = (zahl: number) => String(zahl).padStart(2, '0');
|
||||
return `${jetzt.getFullYear()}-${zweistellig(jetzt.getMonth() + 1)}-${zweistellig(jetzt.getDate())}`;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { alsKartenstand, staendeVon } from '@/lib/repos/karten';
|
||||
import { fach, fachNamen, heutigerStapel } from '@/lib/lernen/sm2';
|
||||
import { heute } from '@/lib/zeit';
|
||||
import { Lernmodus, type Lernkarte } from './Lernmodus';
|
||||
|
||||
export const metadata: Metadata = { title: 'Lernmodus' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const STAPELGROESSE = 20;
|
||||
|
||||
export default async function LernSeite({ searchParams }: { searchParams: Promise<{ block?: string }> }) {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
if (benutzer.rolle !== 'azubi') redirect('/karten');
|
||||
|
||||
const { block } = await searchParams;
|
||||
const alle = inhalte();
|
||||
const gewaehlterBlock = block && alle.blockNach.has(block) ? block : undefined;
|
||||
|
||||
const staende = staendeVon(benutzer.id);
|
||||
const heuteDatum = heute();
|
||||
|
||||
const faellige = heutigerStapel(
|
||||
alle.karten.map((karte) => ({
|
||||
karteId: karte.id,
|
||||
faelligAm: staende.get(karte.id)?.faelligAm ?? null,
|
||||
block: karte.block,
|
||||
})),
|
||||
heuteDatum,
|
||||
STAPELGROESSE,
|
||||
gewaehlterBlock,
|
||||
);
|
||||
|
||||
const karten: Lernkarte[] = faellige.map((eintrag) => {
|
||||
const karte = alle.karteNach.get(eintrag.karteId)!;
|
||||
const stand = alsKartenstand(staende.get(karte.id));
|
||||
return {
|
||||
id: karte.id,
|
||||
frage: karte.frage,
|
||||
antwort: karte.antwort,
|
||||
handoutId: karte.handoutId,
|
||||
handoutTitel: karte.handoutTitel,
|
||||
blockTitel: alle.blockNach.get(karte.block)?.titel ?? karte.block,
|
||||
fach: fach(stand),
|
||||
fachName: fachNamen[fach(stand)] ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
if (karten.length === 0) {
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div className="karte-grund stapel" style={{ textAlign: 'center', padding: '2rem 1.25rem' }}>
|
||||
<p style={{ fontSize: '2.5rem', margin: 0 }} aria-hidden="true">
|
||||
✓
|
||||
</p>
|
||||
<h1 style={{ marginBottom: '0.2rem' }}>Für heute nichts fällig</h1>
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
{gewaehlterBlock
|
||||
? 'In diesem Themenblock ist heute keine Karte dran.'
|
||||
: 'Alle Karten sitzen gerade fest genug. Morgen kommt der nächste Stapel.'}
|
||||
</p>
|
||||
<div className="reihe" style={{ justifyContent: 'center' }}>
|
||||
<Link href="/karten" className="knopf">
|
||||
Zurück zur Übersicht
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Lernmodus karten={karten} block={gewaehlterBlock ?? null} benutzerId={benutzer.id} />;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { alsKartenstand, staendeVon, uebersicht } from '@/lib/repos/karten';
|
||||
import { fach, fachNamen, heutigerStapel } from '@/lib/lernen/sm2';
|
||||
import { alsDatumText, heute } from '@/lib/zeit';
|
||||
|
||||
export const metadata: Metadata = { title: 'Merkkarten' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const STAPELGROESSE = 20;
|
||||
|
||||
export default async function KartenSeite() {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const alle = inhalte();
|
||||
const heuteDatum = heute();
|
||||
|
||||
const istAzubi = benutzer.rolle === 'azubi';
|
||||
const staende = istAzubi ? staendeVon(benutzer.id) : new Map();
|
||||
const stand = istAzubi
|
||||
? uebersicht(benutzer.id, alle.karten.map((k) => k.id), heuteDatum)
|
||||
: { gesamt: alle.karten.length, gelernt: 0, faellig: 0, nieGelernt: alle.karten.length, jeFach: { 1: alle.karten.length, 2: 0, 3: 0, 4: 0, 5: 0 } };
|
||||
|
||||
const faelligeAlle = heutigerStapel(
|
||||
alle.karten.map((karte) => ({
|
||||
karteId: karte.id,
|
||||
faelligAm: staende.get(karte.id)?.faelligAm ?? null,
|
||||
block: karte.block,
|
||||
})),
|
||||
heuteDatum,
|
||||
9999,
|
||||
);
|
||||
|
||||
const jeBlock = alle.bloecke.map((block) => {
|
||||
const desBlocks = alle.karten.filter((k) => k.block === block.id);
|
||||
const faellig = faelligeAlle.filter((f) => f.block === block.id).length;
|
||||
const gelernt = desBlocks.filter((k) => {
|
||||
const zeile = staende.get(k.id);
|
||||
return zeile ? fach(alsKartenstand(zeile)) >= 4 : false;
|
||||
}).length;
|
||||
return { block, gesamt: desBlocks.length, faellig, gelernt };
|
||||
});
|
||||
|
||||
if (!istAzubi) {
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Merkkarten</h1>
|
||||
<p className="leise">
|
||||
{alle.karten.length} Karten aus {alle.handouts.length} Handouts. Gelernt wird nur vom Azubi – du
|
||||
siehst hier, was es gibt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="raster">
|
||||
{jeBlock.map(({ block, gesamt }) => (
|
||||
<section key={block.id} className="karte">
|
||||
<h2 style={{ fontSize: '1rem', marginBottom: '0.2rem' }}>{block.titel}</h2>
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
{gesamt} {gesamt === 1 ? 'Karte' : 'Karten'}
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const heuteStapel = Math.min(stand.faellig, STAPELGROESSE);
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Merkkarten</h1>
|
||||
<p className="leise">
|
||||
{stand.gesamt} Karten insgesamt · {stand.gelernt} sitzen · {stand.nieGelernt} noch nie gelernt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="karte-grund stapel" aria-labelledby="heute">
|
||||
<h2 id="heute" style={{ marginBottom: 0 }}>
|
||||
Heute fällig
|
||||
</h2>
|
||||
|
||||
{stand.faellig === 0 ? (
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
Nichts. Alle Karten liegen weiter hinten – morgen ist wieder etwas dran.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
<strong style={{ fontSize: '2rem' }}>{heuteStapel}</strong>{' '}
|
||||
{heuteStapel === 1 ? 'Karte' : 'Karten'}
|
||||
{stand.faellig > STAPELGROESSE ? (
|
||||
<span className="leise"> von {stand.faellig} fälligen – der Rest kommt morgen</span>
|
||||
) : null}
|
||||
</p>
|
||||
<div>
|
||||
<Link href="/karten/lernen" className="knopf knopf-akzent">
|
||||
Lernen starten
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="stapel" aria-labelledby="bloecke">
|
||||
<div>
|
||||
<h2 id="bloecke" style={{ marginBottom: '0.2rem' }}>
|
||||
Nach Themenblock
|
||||
</h2>
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Vor einer Lernzielkontrolle gezielt üben – hier sind auch Karten dabei, die eigentlich erst später
|
||||
dran wären.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="raster">
|
||||
{jeBlock.map(({ block, gesamt, faellig, gelernt }) => (
|
||||
<section key={block.id} className="karte stapel-eng">
|
||||
<h3 style={{ fontSize: '1rem', marginBottom: '0.2rem' }}>{block.titel}</h3>
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
{gesamt} Karten · {gelernt} sitzen · {faellig} heute fällig
|
||||
</p>
|
||||
<div className="balken" aria-hidden="true">
|
||||
<span style={{ width: `${gesamt === 0 ? 0 : Math.round((gelernt / gesamt) * 100)}%` }} />
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
href={`/karten/lernen?block=${block.id}`}
|
||||
className={`knopf ${faellig > 0 ? '' : 'knopf-zweit'}`}
|
||||
>
|
||||
{faellig > 0 ? `${Math.min(faellig, STAPELGROESSE)} lernen` : 'Trotzdem üben'}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="karte stapel-eng" aria-labelledby="faecher">
|
||||
<h2 id="faecher" style={{ fontSize: '1rem', marginBottom: '0.2rem' }}>
|
||||
Wie fest die Karten sitzen
|
||||
</h2>
|
||||
<ul className="faecher">
|
||||
{[1, 2, 3, 4, 5].map((nummer) => {
|
||||
const anzahl = stand.jeFach[nummer] ?? 0;
|
||||
const anteil = stand.gesamt === 0 ? 0 : Math.round((anzahl / stand.gesamt) * 100);
|
||||
return (
|
||||
<li key={nummer}>
|
||||
<span className="fach-nummer">{nummer}</span>
|
||||
<span className="fach-name">{fachNamen[nummer]}</span>
|
||||
<span className="fach-balken" aria-hidden="true">
|
||||
<span style={{ width: `${anteil}%` }} />
|
||||
</span>
|
||||
<span className="fach-zahl">{anzahl}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{stand.gesamt > stand.nieGelernt ? (
|
||||
<section className="karte" aria-labelledby="naechste">
|
||||
<h2 id="naechste" style={{ fontSize: '1rem', marginBottom: '0.4rem' }}>
|
||||
Als Nächstes fällig
|
||||
</h2>
|
||||
<ul className="stapel-eng" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{[...staende.values()]
|
||||
.filter((z) => z.faelligAm > heuteDatum)
|
||||
.sort((a, b) => a.faelligAm.localeCompare(b.faelligAm))
|
||||
.slice(0, 5)
|
||||
.map((z) => {
|
||||
const karte = alle.karteNach.get(z.karteId);
|
||||
if (!karte) return null;
|
||||
return (
|
||||
<li key={z.karteId} className="reihe" style={{ gap: '0.5rem', flexWrap: 'nowrap' }}>
|
||||
<span className="handout-kuerzel">{karte.handoutId}</span>
|
||||
<span style={{ flex: 1 }}>{karte.frage}</span>
|
||||
<span className="leise">{alsDatumText(z.faelligAm)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useEffect, useRef, useState } from 'react';
|
||||
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
|
||||
import { antwortZwischenspeichern, kontrolleAbgeben, type Rueckmeldung } from './aktionen';
|
||||
import type { SichtbareFrage } from '@/lib/kontrollen/sicht';
|
||||
|
||||
/**
|
||||
* Der Fragebogen.
|
||||
*
|
||||
* Antworten werden beim Tippen zwischengespeichert – eine Kontrolle dauert
|
||||
* 45 Minuten, und ein verlorener Text nach einem versehentlichen Schließen
|
||||
* wäre nicht wiedergutzumachen.
|
||||
*
|
||||
* Zur Musterlösung: Sie steckt gar nicht in den Daten, die diese Komponente
|
||||
* bekommt, solange nicht abgegeben wurde. Das entscheidet der Server.
|
||||
*/
|
||||
export function Fragebogen({
|
||||
kontrolleId,
|
||||
fragen,
|
||||
anfangsantworten,
|
||||
gesamtpunkte,
|
||||
dauerMinuten,
|
||||
}: {
|
||||
kontrolleId: string;
|
||||
fragen: SichtbareFrage[];
|
||||
anfangsantworten: Record<string, string>;
|
||||
gesamtpunkte: number;
|
||||
dauerMinuten: number | null;
|
||||
}) {
|
||||
const [antworten, setzeAntworten] = useState<Record<string, string>>(anfangsantworten);
|
||||
const [gespeichert, setzeGespeichert] = useState<string | null>(null);
|
||||
const [speicherfehler, setzeSpeicherfehler] = useState<string | null>(null);
|
||||
const [zustand, absenden] = useActionState<Rueckmeldung, FormData>(kontrolleAbgeben, {});
|
||||
|
||||
const zeitgeber = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const laufende = zeitgeber.current;
|
||||
return () => {
|
||||
for (const gestellt of Object.values(laufende)) clearTimeout(gestellt);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function aendern(frageId: string, wert: string) {
|
||||
setzeAntworten((v) => ({ ...v, [frageId]: wert }));
|
||||
|
||||
clearTimeout(zeitgeber.current[frageId]);
|
||||
zeitgeber.current[frageId] = setTimeout(async () => {
|
||||
const ergebnis = await antwortZwischenspeichern(kontrolleId, frageId, wert);
|
||||
if ('fehler' in ergebnis) {
|
||||
setzeSpeicherfehler(ergebnis.fehler);
|
||||
} else {
|
||||
setzeSpeicherfehler(null);
|
||||
setzeGespeichert(
|
||||
new Intl.DateTimeFormat('de-DE', { timeStyle: 'medium', timeZone: 'Europe/Berlin' }).format(
|
||||
new Date(ergebnis.gespeichertUm),
|
||||
),
|
||||
);
|
||||
}
|
||||
}, 900);
|
||||
}
|
||||
|
||||
const beantwortet = fragen.filter((f) => (antworten[f.id] ?? '').trim() !== '').length;
|
||||
|
||||
return (
|
||||
<form action={absenden} className="stapel">
|
||||
<input type="hidden" name="kontrolleId" value={kontrolleId} />
|
||||
|
||||
<div className="kontrolle-kopf karte">
|
||||
<div>
|
||||
<strong>
|
||||
{beantwortet} von {fragen.length} Fragen beantwortet
|
||||
</strong>
|
||||
<span className="leise" style={{ display: 'block' }}>
|
||||
{gesamtpunkte} Punkte insgesamt
|
||||
{dauerMinuten ? ` · ${dauerMinuten} Minuten vorgesehen` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="leise speicherstand" aria-live="polite">
|
||||
{speicherfehler ? (
|
||||
<span style={{ color: 'var(--fehler)' }}>{speicherfehler}</span>
|
||||
) : gespeichert ? (
|
||||
`Zwischengespeichert um ${gespeichert}`
|
||||
) : (
|
||||
'Wird beim Tippen zwischengespeichert'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ol className="fragenliste">
|
||||
{fragen.map((frage, stelle) => (
|
||||
<li key={frage.id}>
|
||||
<div className="frage-kopf">
|
||||
<span className="frage-nummer">{stelle + 1}</span>
|
||||
<p className="frage-text">{frage.frage}</p>
|
||||
<span className="frage-punkte">
|
||||
{frage.punkte} {frage.punkte === 1 ? 'Punkt' : 'Punkte'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label htmlFor={`antwort-${frage.id}`} className="nur-lesehilfe">
|
||||
Antwort auf Frage {stelle + 1}
|
||||
</label>
|
||||
<textarea
|
||||
id={`antwort-${frage.id}`}
|
||||
name={`antwort-${frage.id}`}
|
||||
rows={5}
|
||||
maxLength={20000}
|
||||
value={antworten[frage.id] ?? ''}
|
||||
onChange={(e) => aendern(frage.id, e.target.value)}
|
||||
placeholder="Antworte in ganzen Sätzen – in der Abschlussprüfung wird ebenfalls nicht in Stichworten bewertet."
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Meldung zustand={zustand} />
|
||||
|
||||
<section className="karte-grund stapel-eng">
|
||||
<p style={{ margin: 0 }}>
|
||||
<strong>Abgeben ist endgültig.</strong> Danach lässt sich nichts mehr ändern – dafür siehst du die
|
||||
Musterlösungen.
|
||||
</p>
|
||||
{beantwortet < fragen.length ? (
|
||||
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
|
||||
{fragen.length - beantwortet}{' '}
|
||||
{fragen.length - beantwortet === 1 ? 'Frage ist' : 'Fragen sind'} noch unbeantwortet.
|
||||
</p>
|
||||
) : null}
|
||||
<div>
|
||||
<Absenden className="knopf knopf-akzent" wartetext="Wird abgegeben …">
|
||||
Kontrolle abgeben
|
||||
</Absenden>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
|
||||
import { kontrolleKorrigieren, type Rueckmeldung } from './aktionen';
|
||||
import { noteZu } from '@/lib/kontrollen/bewertung';
|
||||
|
||||
export type Korrekturfrage = {
|
||||
id: string;
|
||||
frage: string;
|
||||
punkte: number;
|
||||
musterloesung: string;
|
||||
antwort: string;
|
||||
vergeben: number | null;
|
||||
anmerkung: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Korrekturansicht des Ausbilders.
|
||||
*
|
||||
* Je Frage: Fragetext, die Antwort des Azubis, die Musterlösung daneben, ein
|
||||
* Punktefeld und ein Feld für eine Anmerkung. Die Zwischensumme und die daraus
|
||||
* folgende Note laufen beim Tippen mit, damit man beim Verteilen der Punkte
|
||||
* sieht, wo man landet.
|
||||
*/
|
||||
export function Korrektur({
|
||||
kontrolleId,
|
||||
azubiId,
|
||||
fragen,
|
||||
anmerkung,
|
||||
}: {
|
||||
kontrolleId: string;
|
||||
azubiId: string;
|
||||
fragen: Korrekturfrage[];
|
||||
anmerkung: string;
|
||||
}) {
|
||||
const [punkte, setzePunkte] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(fragen.map((f) => [f.id, f.vergeben === null ? '' : String(f.vergeben)])),
|
||||
);
|
||||
const [zustand, korrigieren] = useActionState<Rueckmeldung, FormData>(kontrolleKorrigieren, {});
|
||||
|
||||
const moeglich = fragen.reduce((summe, f) => summe + f.punkte, 0);
|
||||
const erreicht = fragen.reduce((summe, f) => {
|
||||
const wert = Number((punkte[f.id] ?? '').replace(',', '.'));
|
||||
return summe + (Number.isFinite(wert) && wert > 0 ? Math.min(wert, f.punkte) : 0);
|
||||
}, 0);
|
||||
const offen = fragen.filter((f) => (punkte[f.id] ?? '').trim() === '').length;
|
||||
const prozent = moeglich > 0 ? Math.round((erreicht / moeglich) * 100) : 0;
|
||||
const note = noteZu(prozent);
|
||||
|
||||
return (
|
||||
<form action={korrigieren} className="stapel">
|
||||
<input type="hidden" name="kontrolleId" value={kontrolleId} />
|
||||
<input type="hidden" name="azubiId" value={azubiId} />
|
||||
|
||||
<div className="kontrolle-kopf karte" style={{ position: 'sticky', top: 0, zIndex: 5 }}>
|
||||
<div>
|
||||
<strong>
|
||||
{runde(erreicht)} von {moeglich} Punkten · {prozent} %
|
||||
</strong>
|
||||
<span className="leise" style={{ display: 'block' }}>
|
||||
{offen > 0
|
||||
? `${offen} ${offen === 1 ? 'Frage' : 'Fragen'} noch ohne Punkte – Ergebnis vorläufig`
|
||||
: `Note ${note.note} (${note.text})`}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`note-anzeige${offen > 0 ? ' note-vorlaeufig' : ''}`} aria-hidden="true">
|
||||
{note.note}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ol className="fragenliste">
|
||||
{fragen.map((frage, stelle) => (
|
||||
<li key={frage.id}>
|
||||
<div className="frage-kopf">
|
||||
<span className="frage-nummer">{stelle + 1}</span>
|
||||
<p className="frage-text">{frage.frage}</p>
|
||||
<span className="frage-punkte">
|
||||
{frage.punkte} {frage.punkte === 1 ? 'Punkt' : 'Punkte'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="korrektur-spalten">
|
||||
<div>
|
||||
<span className="wochentag-marke">Antwort des Azubis</span>
|
||||
{frage.antwort.trim() ? (
|
||||
<p className="notiz-anzeige">{frage.antwort}</p>
|
||||
) : (
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Nicht beantwortet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="wochentag-marke">Musterlösung</span>
|
||||
<p className="notiz-anzeige musterloesung">{frage.musterloesung}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="korrektur-bewertung">
|
||||
<div>
|
||||
<label htmlFor={`punkte-${frage.id}`}>Punkte (von {frage.punkte})</label>
|
||||
<input
|
||||
id={`punkte-${frage.id}`}
|
||||
name={`punkte-${frage.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={frage.punkte}
|
||||
step={0.5}
|
||||
value={punkte[frage.id] ?? ''}
|
||||
onChange={(e) => setzePunkte((v) => ({ ...v, [frage.id]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<label htmlFor={`anmerkung-${frage.id}`}>Anmerkung zu dieser Frage</label>
|
||||
<input
|
||||
id={`anmerkung-${frage.id}`}
|
||||
name={`anmerkung-${frage.id}`}
|
||||
type="text"
|
||||
maxLength={500}
|
||||
defaultValue={frage.anmerkung}
|
||||
placeholder="z. B. Rechenweg fehlt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<section className="karte-grund stapel-eng">
|
||||
<label htmlFor="gesamtanmerkung">Anmerkung zur ganzen Kontrolle</label>
|
||||
<textarea
|
||||
id="gesamtanmerkung"
|
||||
name="anmerkung"
|
||||
rows={3}
|
||||
maxLength={4000}
|
||||
defaultValue={anmerkung}
|
||||
placeholder="Was lief gut, woran sollte er arbeiten?"
|
||||
/>
|
||||
|
||||
<Meldung zustand={zustand} />
|
||||
|
||||
<div>
|
||||
<Absenden className="knopf knopf-akzent">
|
||||
{offen > 0 ? 'Zwischenstand speichern' : 'Korrektur abschließen'}
|
||||
</Absenden>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function runde(wert: number): number {
|
||||
return Math.round(wert * 100) / 100;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { antwortenVon, ergebnisVon } from '@/lib/repos/kontrollen';
|
||||
import { darfFragenSehen, sichtbareKontrolle, zustandVon } from '@/lib/kontrollen/sicht';
|
||||
import { alsZeitpunktText } from '@/lib/zeit';
|
||||
import { Fragebogen } from '../Fragebogen';
|
||||
import { Korrektur, type Korrekturfrage } from '../Korrektur';
|
||||
import { standZumLesen } from '../aktionen';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ kontrolleId: string }> }): Promise<Metadata> {
|
||||
const { kontrolleId } = await params;
|
||||
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
|
||||
return { title: inhalt?.titel ?? 'Lernzielkontrolle' };
|
||||
}
|
||||
|
||||
export default async function KontrollSeite({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ kontrolleId: string }>;
|
||||
searchParams: Promise<{ azubi?: string }>;
|
||||
}) {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const { kontrolleId } = await params;
|
||||
const { azubi: azubiParameter } = await searchParams;
|
||||
|
||||
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
|
||||
if (!inhalt) notFound();
|
||||
|
||||
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (azubiParameter ?? '');
|
||||
if (!azubiId) notFound();
|
||||
|
||||
const gelesen = await standZumLesen(azubiId, kontrolleId);
|
||||
if (!gelesen) notFound();
|
||||
|
||||
const { bezug, stand } = gelesen;
|
||||
const istAzubi = benutzer.rolle === 'azubi';
|
||||
const azubi = istAzubi ? benutzer : benutzerNachId(azubiId);
|
||||
if (!azubi) notFound();
|
||||
|
||||
const bearbeitungsstand = {
|
||||
azubiId,
|
||||
freigegebenAm: stand.freigegebenAm,
|
||||
abgegebenAm: stand.abgegebenAm,
|
||||
};
|
||||
|
||||
// Vor der Freigabe bekommt der Azubi die Fragen gar nicht erst zu sehen.
|
||||
if (!darfFragenSehen(benutzer, bearbeitungsstand, bezug)) {
|
||||
return (
|
||||
<div className="stapel">
|
||||
<nav className="leise nicht-drucken">
|
||||
<Link href="/kontrollen">Lernzielkontrollen</Link>
|
||||
</nav>
|
||||
<div className="karte-grund stapel" style={{ textAlign: 'center', padding: '2rem 1.25rem' }}>
|
||||
<p style={{ fontSize: '2.2rem', margin: 0 }} aria-hidden="true">
|
||||
🔒
|
||||
</p>
|
||||
<h1 style={{ marginBottom: '0.2rem' }}>Noch nicht freigegeben</h1>
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
Dein Ausbilder gibt diese Kontrolle frei, wenn es soweit ist. Bis dahin sind die Fragen nicht
|
||||
einsehbar.
|
||||
</p>
|
||||
<div className="reihe" style={{ justifyContent: 'center' }}>
|
||||
<Link href="/kontrollen" className="knopf">
|
||||
Zurück zur Übersicht
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Hier wird die Musterlösung entfernt, falls sie nicht gezeigt werden darf.
|
||||
const sicht = sichtbareKontrolle(inhalt, benutzer, bearbeitungsstand, bezug);
|
||||
const antworten = antwortenVon(stand.id);
|
||||
const zustand = zustandVon({ ...bearbeitungsstand, korrigiertAm: stand.korrigiertAm });
|
||||
const ergebnis = stand.abgegebenAm ? ergebnisVon(stand.id, inhalt) : null;
|
||||
const korrektor = stand.korrigiertVon ? benutzerNachId(stand.korrigiertVon) : null;
|
||||
|
||||
return (
|
||||
<article className="stapel">
|
||||
<nav className="leise nicht-drucken" aria-label="Wo du bist">
|
||||
<Link href="/kontrollen">Lernzielkontrollen</Link>
|
||||
{!istAzubi ? ` › ${azubi.name}` : ''}
|
||||
</nav>
|
||||
|
||||
<header className="stapel-eng">
|
||||
<div className="reihe" style={{ gap: '0.6rem' }}>
|
||||
<span className="kontroll-woche kontroll-woche-gross">Woche {inhalt.woche}</span>
|
||||
{ergebnis && stand.korrigiertAm ? (
|
||||
<span className="marke-status" data-ton="gut">
|
||||
✓ {zahl(ergebnis.erreicht)} von {zahl(ergebnis.moeglich)} Punkten · {ergebnis.prozent} % · Note{' '}
|
||||
{ergebnis.note}
|
||||
</span>
|
||||
) : null}
|
||||
{zustand === 'abgegeben' ? (
|
||||
<span className="marke-status" data-ton="warnung">
|
||||
▲ abgegeben {alsZeitpunktText(stand.abgegebenAm)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h1 style={{ marginBottom: '0.2rem' }}>{inhalt.titel}</h1>
|
||||
{inhalt.hinweis ? <p className="handout-worum">{inhalt.hinweis}</p> : null}
|
||||
{!istAzubi ? <p className="leise">{azubi.name}</p> : null}
|
||||
</header>
|
||||
|
||||
{/* --- Ergebnis, sobald korrigiert --- */}
|
||||
|
||||
{ergebnis && stand.korrigiertAm ? (
|
||||
<section className="karte stapel-eng" aria-labelledby="ergebnis">
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id="ergebnis" style={{ margin: 0, fontSize: '1rem' }}>
|
||||
Ergebnis
|
||||
</h2>
|
||||
<span className="leise">
|
||||
korrigiert {alsZeitpunktText(stand.korrigiertAm)}
|
||||
{korrektor ? ` von ${korrektor.name}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ergebnis-balken">
|
||||
<div className="balken" style={{ height: '14px' }}>
|
||||
<span style={{ width: `${ergebnis.prozent}%` }} />
|
||||
</div>
|
||||
<strong>
|
||||
{zahl(ergebnis.erreicht)} / {zahl(ergebnis.moeglich)} · {ergebnis.prozent} % · Note {ergebnis.note}{' '}
|
||||
({ergebnis.notentext})
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
{stand.anmerkung ? <p className="notiz-anzeige">{stand.anmerkung}</p> : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* --- Arbeitsansicht --- */}
|
||||
|
||||
{istAzubi && zustand === 'freigegeben' ? (
|
||||
<Fragebogen
|
||||
kontrolleId={inhalt.id}
|
||||
fragen={sicht.fragen}
|
||||
anfangsantworten={Object.fromEntries([...antworten].map(([id, a]) => [id, a.antwort]))}
|
||||
gesamtpunkte={sicht.gesamtpunkte}
|
||||
dauerMinuten={sicht.dauerMinuten}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* --- Nach der Abgabe: Antworten und Musterlösungen --- */}
|
||||
|
||||
{istAzubi && stand.abgegebenAm ? (
|
||||
<ol className="fragenliste">
|
||||
{sicht.fragen.map((frage, stelle) => {
|
||||
const eigene = antworten.get(frage.id);
|
||||
return (
|
||||
<li key={frage.id}>
|
||||
<div className="frage-kopf">
|
||||
<span className="frage-nummer">{stelle + 1}</span>
|
||||
<p className="frage-text">{frage.frage}</p>
|
||||
<span className="frage-punkte">
|
||||
{eigene?.punkte != null ? `${zahl(eigene.punkte)} / ` : ''}
|
||||
{frage.punkte} {frage.punkte === 1 ? 'Punkt' : 'Punkte'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="korrektur-spalten">
|
||||
<div>
|
||||
<span className="wochentag-marke">Deine Antwort</span>
|
||||
{eigene?.antwort.trim() ? (
|
||||
<p className="notiz-anzeige">{eigene.antwort}</p>
|
||||
) : (
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Nicht beantwortet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="wochentag-marke">Musterlösung</span>
|
||||
<p className="notiz-anzeige musterloesung">{frage.musterloesung}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{eigene?.anmerkung ? (
|
||||
<p className="hinweis hinweis-warnung" style={{ marginTop: '0.5rem', marginBottom: 0 }}>
|
||||
{eigene.anmerkung}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : null}
|
||||
|
||||
{/* --- Korrektur durch den Ausbilder --- */}
|
||||
|
||||
{!istAzubi && stand.abgegebenAm ? (
|
||||
<Korrektur
|
||||
kontrolleId={inhalt.id}
|
||||
azubiId={azubiId}
|
||||
anmerkung={stand.anmerkung ?? ''}
|
||||
fragen={inhalt.fragen.map<Korrekturfrage>((frage) => ({
|
||||
id: frage.id,
|
||||
frage: frage.frage,
|
||||
punkte: frage.punkte,
|
||||
musterloesung: frage.musterloesung,
|
||||
antwort: antworten.get(frage.id)?.antwort ?? '',
|
||||
vergeben: antworten.get(frage.id)?.punkte ?? null,
|
||||
anmerkung: antworten.get(frage.id)?.anmerkung ?? '',
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* --- Ausbilder vor der Abgabe: nur durchsehen --- */}
|
||||
|
||||
{!istAzubi && !stand.abgegebenAm ? (
|
||||
<>
|
||||
<p className="hinweis hinweis-warnung">
|
||||
{zustand === 'gesperrt'
|
||||
? 'Diese Kontrolle ist für den Azubi noch nicht freigegeben – du siehst sie zum Durchsehen.'
|
||||
: 'Freigegeben, aber noch nicht abgegeben. Die Antworten erscheinen hier, sobald der Azubi abgibt.'}
|
||||
</p>
|
||||
|
||||
<ol className="fragenliste">
|
||||
{sicht.fragen.map((frage, stelle) => (
|
||||
<li key={frage.id}>
|
||||
<div className="frage-kopf">
|
||||
<span className="frage-nummer">{stelle + 1}</span>
|
||||
<p className="frage-text">{frage.frage}</p>
|
||||
<span className="frage-punkte">
|
||||
{frage.punkte} {frage.punkte === 1 ? 'Punkt' : 'Punkte'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="wochentag-marke">Musterlösung</span>
|
||||
<p className="notiz-anzeige musterloesung">{frage.musterloesung}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<p className="leise nicht-drucken" style={{ marginBottom: 0 }}>
|
||||
Quelle: <code>content/lernzielkontrollen/{inhalt.id}.yaml</code>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function zahl(wert: number): string {
|
||||
return wert.toString().replace('.', ',');
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { ausbildungVonAzubi, erfordereBenutzer, erfordereBewertungsrecht } from '@/lib/auth/rechte';
|
||||
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import {
|
||||
abgeben,
|
||||
antwortSpeichern,
|
||||
freigabeZuruecknehmen,
|
||||
freigeben,
|
||||
korrigieren,
|
||||
sorgeFuerStand,
|
||||
standVon,
|
||||
} from '@/lib/repos/kontrollen';
|
||||
import { darfBearbeiten } from '@/lib/kontrollen/sicht';
|
||||
|
||||
export type Rueckmeldung = { fehler?: string; erfolg?: string };
|
||||
|
||||
function aktualisiere(kontrolleId: string) {
|
||||
revalidatePath('/kontrollen');
|
||||
revalidatePath(`/kontrollen/${kontrolleId}`);
|
||||
revalidatePath('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Antwort zwischenspeichern.
|
||||
*
|
||||
* Wird beim Tippen mit kurzer Verzögerung aufgerufen. Gibt bewusst **nichts**
|
||||
* zurück außer einem Zeitstempel – insbesondere keine Musterlösung, auch nicht
|
||||
* versehentlich über einen mitgegebenen Datensatz.
|
||||
*/
|
||||
export async function antwortZwischenspeichern(
|
||||
kontrolleId: string,
|
||||
frageId: string,
|
||||
antwort: string,
|
||||
): Promise<{ gespeichertUm: string } | { fehler: string }> {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
|
||||
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
|
||||
if (!inhalt) return { fehler: 'Diese Lernzielkontrolle gibt es nicht.' };
|
||||
if (!inhalt.fragen.some((f) => f.id === frageId)) return { fehler: 'Diese Frage gibt es nicht.' };
|
||||
if (antwort.length > 20000) return { fehler: 'Die Antwort ist zu lang.' };
|
||||
|
||||
const stand = standVon(benutzer.id, kontrolleId);
|
||||
if (!stand) return { fehler: 'Die Kontrolle ist nicht freigegeben.' };
|
||||
|
||||
// Die eine Regel, auf die es ankommt: geschrieben wird nur zwischen
|
||||
// Freigabe und Abgabe, und nur vom Azubi selbst.
|
||||
if (!darfBearbeiten(benutzer, { azubiId: stand.benutzerId, freigegebenAm: stand.freigegebenAm, abgegebenAm: stand.abgegebenAm })) {
|
||||
return { fehler: 'Diese Kontrolle lässt sich gerade nicht bearbeiten.' };
|
||||
}
|
||||
|
||||
antwortSpeichern(stand.id, frageId, antwort);
|
||||
return { gespeichertUm: new Date().toISOString() };
|
||||
}
|
||||
|
||||
export async function kontrolleAbgeben(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const kontrolleId = String(formular.get('kontrolleId') ?? '');
|
||||
|
||||
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
|
||||
if (!inhalt) return { fehler: 'Diese Lernzielkontrolle gibt es nicht.' };
|
||||
|
||||
const stand = standVon(benutzer.id, kontrolleId);
|
||||
if (!stand) return { fehler: 'Die Kontrolle ist nicht freigegeben.' };
|
||||
if (!darfBearbeiten(benutzer, { azubiId: stand.benutzerId, freigegebenAm: stand.freigegebenAm, abgegebenAm: stand.abgegebenAm })) {
|
||||
return { fehler: 'Diese Kontrolle lässt sich gerade nicht abgeben.' };
|
||||
}
|
||||
|
||||
// Letzten Stand der Antworten übernehmen, damit nichts verlorengeht.
|
||||
for (const frage of inhalt.fragen) {
|
||||
const antwort = formular.get(`antwort-${frage.id}`);
|
||||
if (typeof antwort === 'string') antwortSpeichern(stand.id, frage.id, antwort);
|
||||
}
|
||||
|
||||
abgeben(stand.id);
|
||||
aktualisiere(kontrolleId);
|
||||
|
||||
return { erfolg: 'Abgegeben. Die Musterlösungen stehen jetzt unter den Fragen.' };
|
||||
}
|
||||
|
||||
// --- Ausbilder -------------------------------------------------------------
|
||||
|
||||
export async function kontrolleFreigeben(formular: FormData): Promise<void> {
|
||||
const ausbilder = await erfordereBenutzer();
|
||||
const azubiId = String(formular.get('azubiId') ?? '');
|
||||
const kontrolleId = String(formular.get('kontrolleId') ?? '');
|
||||
|
||||
erfordereBewertungsrecht(ausbilder, azubiId);
|
||||
if (!inhalte().kontrolleNach.has(kontrolleId)) return;
|
||||
|
||||
freigeben(azubiId, kontrolleId, ausbilder.id);
|
||||
aktualisiere(kontrolleId);
|
||||
}
|
||||
|
||||
export async function freigabeZurueck(formular: FormData): Promise<void> {
|
||||
const ausbilder = await erfordereBenutzer();
|
||||
const azubiId = String(formular.get('azubiId') ?? '');
|
||||
const kontrolleId = String(formular.get('kontrolleId') ?? '');
|
||||
|
||||
erfordereBewertungsrecht(ausbilder, azubiId);
|
||||
|
||||
const stand = standVon(azubiId, kontrolleId);
|
||||
// Eine abgegebene Kontrolle wird nicht wieder zugemacht.
|
||||
if (!stand || stand.abgegebenAm) return;
|
||||
|
||||
freigabeZuruecknehmen(stand.id);
|
||||
aktualisiere(kontrolleId);
|
||||
}
|
||||
|
||||
export async function kontrolleKorrigieren(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const ausbilder = await erfordereBenutzer();
|
||||
const azubiId = String(formular.get('azubiId') ?? '');
|
||||
const kontrolleId = String(formular.get('kontrolleId') ?? '');
|
||||
|
||||
erfordereBewertungsrecht(ausbilder, azubiId);
|
||||
|
||||
const inhalt = inhalte().kontrolleNach.get(kontrolleId);
|
||||
if (!inhalt) return { fehler: 'Diese Lernzielkontrolle gibt es nicht.' };
|
||||
|
||||
const stand = standVon(azubiId, kontrolleId);
|
||||
if (!stand) return { fehler: 'Zu dieser Kontrolle gibt es noch keinen Stand.' };
|
||||
if (!stand.abgegebenAm) return { fehler: 'Die Kontrolle ist noch nicht abgegeben.' };
|
||||
|
||||
const bewertungen = inhalt.fragen.map((frage) => {
|
||||
const roh = String(formular.get(`punkte-${frage.id}`) ?? '').replace(',', '.').trim();
|
||||
const gelesen = Number(roh);
|
||||
const anmerkung = String(formular.get(`anmerkung-${frage.id}`) ?? '').trim();
|
||||
|
||||
return {
|
||||
frageId: frage.id,
|
||||
punkte: roh === '' || !Number.isFinite(gelesen) ? null : gelesen,
|
||||
anmerkung: anmerkung || null,
|
||||
};
|
||||
});
|
||||
|
||||
const gesamtanmerkung = String(formular.get('anmerkung') ?? '').trim();
|
||||
if (gesamtanmerkung.length > 4000) return { fehler: 'Die Anmerkung ist zu lang.' };
|
||||
|
||||
const ergebnis = korrigieren(stand.id, ausbilder.id, inhalt, bewertungen, gesamtanmerkung || null);
|
||||
aktualisiere(kontrolleId);
|
||||
|
||||
return ergebnis.vollstaendig
|
||||
? {
|
||||
erfolg: `Korrigiert: ${zahl(ergebnis.erreicht)} von ${zahl(ergebnis.moeglich)} Punkten – ${ergebnis.prozent} %, Note ${ergebnis.note} (${ergebnis.notentext}).`,
|
||||
}
|
||||
: {
|
||||
erfolg: `Zwischenstand gespeichert. ${ergebnis.offeneFragen} ${ergebnis.offeneFragen === 1 ? 'Frage ist' : 'Fragen sind'} noch ohne Punkte – erst dann gilt die Kontrolle als korrigiert.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Prüft den Lesezugriff und liefert den Stand. Wird von Seiten benutzt. */
|
||||
export async function standZumLesen(azubiId: string, kontrolleId: string) {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
|
||||
const bezug = ausbildungVonAzubi(azubiId);
|
||||
if (!darfAzubiDatenSehen(benutzer, azubiId, bezug)) return null;
|
||||
|
||||
const stand = sorgeFuerStand(azubiId, kontrolleId);
|
||||
return { benutzer, bezug, stand };
|
||||
}
|
||||
|
||||
function zahl(wert: number): string {
|
||||
return wert.toString().replace('.', ',');
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { ergebnisVon, staendeVon } from '@/lib/repos/kontrollen';
|
||||
import { gesamtpunkte, zustandVon, type Zustand } from '@/lib/kontrollen/sicht';
|
||||
import { verlauf } from '@/lib/kontrollen/bewertung';
|
||||
import { alsZeitpunktText } from '@/lib/zeit';
|
||||
import { kontrolleFreigeben, freigabeZurueck } from './aktionen';
|
||||
|
||||
export const metadata: Metadata = { title: 'Lernzielkontrollen' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const zustandsmarke: Record<Zustand, { text: string; ton: string; zeichen: string }> = {
|
||||
gesperrt: { text: 'nicht freigegeben', ton: 'neutral', zeichen: '○' },
|
||||
freigegeben: { text: 'freigegeben', ton: 'warnung', zeichen: '▶' },
|
||||
abgegeben: { text: 'abgegeben', ton: 'warnung', zeichen: '▲' },
|
||||
korrigiert: { text: 'korrigiert', ton: 'gut', zeichen: '✓' },
|
||||
};
|
||||
|
||||
export default async function KontrollenSeite() {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const alle = inhalte();
|
||||
|
||||
if (benutzer.rolle === 'azubi') {
|
||||
const staende = staendeVon(benutzer.id);
|
||||
|
||||
const zeilen = alle.kontrollen.map((inhalt) => {
|
||||
const stand = staende.get(inhalt.id);
|
||||
const zustand = zustandVon({
|
||||
azubiId: benutzer.id,
|
||||
freigegebenAm: stand?.freigegebenAm ?? null,
|
||||
abgegebenAm: stand?.abgegebenAm ?? null,
|
||||
korrigiertAm: stand?.korrigiertAm ?? null,
|
||||
});
|
||||
const ergebnis = stand && stand.korrigiertAm ? ergebnisVon(stand.id, inhalt) : null;
|
||||
return { inhalt, zustand, ergebnis, stand };
|
||||
});
|
||||
|
||||
const korrigierte = zeilen
|
||||
.filter((z) => z.ergebnis)
|
||||
.map((z) => ({ titel: z.inhalt.titel, prozent: z.ergebnis!.prozent, note: z.ergebnis!.note }));
|
||||
const stand = verlauf(korrigierte);
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Lernzielkontrollen</h1>
|
||||
<p className="leise">
|
||||
{alle.kontrollen.length} Kontrollen · {korrigierte.length} korrigiert
|
||||
{stand.anzahl > 0 ? ` · Schnitt ${stand.schnittProzent} %, Note ${zahl(stand.schnittNote)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="kontrollliste">
|
||||
{zeilen.map(({ inhalt, zustand, ergebnis }) => {
|
||||
const marke = zustandsmarke[zustand];
|
||||
const offen = zustand === 'gesperrt';
|
||||
|
||||
return (
|
||||
<li key={inhalt.id}>
|
||||
{offen ? (
|
||||
<div className="kontrollzeile kontrollzeile-gesperrt">
|
||||
<Zeileninhalt inhalt={inhalt} marke={marke} ergebnis={ergebnis} />
|
||||
</div>
|
||||
) : (
|
||||
<Link href={`/kontrollen/${inhalt.id}`} className="kontrollzeile">
|
||||
<Zeileninhalt inhalt={inhalt} marke={marke} ergebnis={ergebnis} />
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p className="leise">
|
||||
Eine Kontrolle wird von deinem Ausbilder freigegeben. Vorher sind die Fragen nicht einsehbar – die
|
||||
Musterlösungen erst nach dem Absenden.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Ausbilder ---
|
||||
const meine = ausbildungenVonAusbilder(benutzer.id);
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Lernzielkontrollen</h1>
|
||||
<p className="leise">Freigeben, korrigieren, Ergebnisse ansehen.</p>
|
||||
</div>
|
||||
|
||||
{meine.length === 0 ? (
|
||||
<p className="hinweis hinweis-warnung">
|
||||
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
|
||||
</p>
|
||||
) : (
|
||||
meine.map(({ azubi }) => {
|
||||
const staende = staendeVon(azubi.id);
|
||||
|
||||
return (
|
||||
<section key={azubi.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
|
||||
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
|
||||
{azubi.name}
|
||||
</h2>
|
||||
|
||||
<ul className="kontrollliste">
|
||||
{alle.kontrollen.map((inhalt) => {
|
||||
const stand = staende.get(inhalt.id);
|
||||
const zustand = zustandVon({
|
||||
azubiId: azubi.id,
|
||||
freigegebenAm: stand?.freigegebenAm ?? null,
|
||||
abgegebenAm: stand?.abgegebenAm ?? null,
|
||||
korrigiertAm: stand?.korrigiertAm ?? null,
|
||||
});
|
||||
const marke = zustandsmarke[zustand];
|
||||
const ergebnis = stand && stand.abgegebenAm ? ergebnisVon(stand.id, inhalt) : null;
|
||||
|
||||
return (
|
||||
<li key={inhalt.id}>
|
||||
<div className="kontrollzeile">
|
||||
<Link href={`/kontrollen/${inhalt.id}?azubi=${azubi.id}`} className="kontrollzeile-haupt">
|
||||
<span className="kontroll-woche">Woche {inhalt.woche}</span>
|
||||
<span className="kontroll-titel">
|
||||
{inhalt.titel}
|
||||
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
|
||||
{inhalt.fragen.length} Fragen · {gesamtpunkte(inhalt)} Punkte
|
||||
{stand?.abgegebenAm ? ` · abgegeben ${alsZeitpunktText(stand.abgegebenAm)}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="reihe" style={{ gap: '0.4rem' }}>
|
||||
{ergebnis && stand?.korrigiertAm ? (
|
||||
<span className="marke-status" data-ton="gut">
|
||||
{ergebnis.prozent} % · Note {ergebnis.note}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span className="marke-status" data-ton={marke.ton}>
|
||||
{marke.zeichen} {marke.text}
|
||||
</span>
|
||||
|
||||
{zustand === 'gesperrt' ? (
|
||||
<form action={kontrolleFreigeben}>
|
||||
<input type="hidden" name="azubiId" value={azubi.id} />
|
||||
<input type="hidden" name="kontrolleId" value={inhalt.id} />
|
||||
<button type="submit" className="knopf knopf-akzent">
|
||||
Freigeben
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{zustand === 'freigegeben' ? (
|
||||
<form action={freigabeZurueck}>
|
||||
<input type="hidden" name="azubiId" value={azubi.id} />
|
||||
<input type="hidden" name="kontrolleId" value={inhalt.id} />
|
||||
<button type="submit" className="knopf knopf-still">
|
||||
Freigabe zurück
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Zeileninhalt({
|
||||
inhalt,
|
||||
marke,
|
||||
ergebnis,
|
||||
}: {
|
||||
inhalt: { id: string; woche: number; titel: string; fragen: { punkte: number }[] };
|
||||
marke: { text: string; ton: string; zeichen: string };
|
||||
ergebnis: { prozent: number; note: number } | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<span className="kontroll-woche">Woche {inhalt.woche}</span>
|
||||
<span className="kontroll-titel">
|
||||
{inhalt.titel}
|
||||
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
|
||||
{inhalt.fragen.length} Fragen · {inhalt.fragen.reduce((s, f) => s + f.punkte, 0)} Punkte
|
||||
</span>
|
||||
</span>
|
||||
{ergebnis ? (
|
||||
<span className="marke-status" data-ton="gut">
|
||||
{ergebnis.prozent} % · Note {ergebnis.note}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="marke-status" data-ton={marke.ton}>
|
||||
{marke.zeichen} {marke.text}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function zahl(wert: number): string {
|
||||
return wert.toString().replace('.', ',');
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { Kopfzeile, type Navigationseintrag } from '@/komponenten/Kopfzeile';
|
||||
import { Netzzustand } from '@/komponenten/Netzzustand';
|
||||
import { abmelden } from '../anmelden/aktionen';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -9,12 +10,27 @@ export const dynamic = 'force-dynamic';
|
||||
const fuerAzubi: Navigationseintrag[] = [
|
||||
{ pfad: '/', titel: 'Woche' },
|
||||
{ pfad: '/handouts', titel: 'Handouts' },
|
||||
{ pfad: '/karten', titel: 'Merkkarten' },
|
||||
{ pfad: '/aufgaben', titel: 'Aufgaben' },
|
||||
{ pfad: '/checklisten', titel: 'Checklisten' },
|
||||
{ pfad: '/berichtsheft', titel: 'Berichtsheft' },
|
||||
{ pfad: '/kontrollen', titel: 'Kontrollen' },
|
||||
{ pfad: '/auswertung', titel: 'Mein Stand' },
|
||||
{ pfad: '/feedback', titel: 'Feedback' },
|
||||
{ pfad: '/glossar', titel: 'Glossar' },
|
||||
];
|
||||
|
||||
const fuerAusbilder: Navigationseintrag[] = [
|
||||
{ pfad: '/', titel: 'Überblick' },
|
||||
{ pfad: '/wochenplan', titel: 'Wochenplan' },
|
||||
{ pfad: '/handouts', titel: 'Handouts' },
|
||||
{ pfad: '/karten', titel: 'Merkkarten' },
|
||||
{ pfad: '/aufgaben', titel: 'Aufgaben' },
|
||||
{ pfad: '/checklisten', titel: 'Checklisten' },
|
||||
{ pfad: '/berichtsheft', titel: 'Berichtshefte' },
|
||||
{ pfad: '/kontrollen', titel: 'Kontrollen' },
|
||||
{ pfad: '/auswertung', titel: 'Auswertung' },
|
||||
{ pfad: '/feedback', titel: 'Feedback' },
|
||||
{ pfad: '/glossar', titel: 'Glossar' },
|
||||
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
||||
];
|
||||
@@ -34,6 +50,7 @@ export default async function AngemeldetLayout({ children }: { children: React.R
|
||||
rolle={benutzer.rolle === 'ausbilder' ? 'Ausbilder' : 'Azubi'}
|
||||
abmeldeAktion={abmelden}
|
||||
/>
|
||||
<Netzzustand benutzerId={benutzer.id} />
|
||||
<main id="inhalt" className="huelle">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
/**
|
||||
* „Nicht gefunden" im angemeldeten Bereich.
|
||||
*
|
||||
* Diese Seite erscheint auch dann, wenn jemand Daten anfragt, die ihn nichts
|
||||
* angehen. Die Meldung ist deshalb absichtlich unbestimmt: Ob es die Daten
|
||||
* gibt, wird nicht verraten.
|
||||
*/
|
||||
export default function NichtGefunden() {
|
||||
return (
|
||||
<div className="karte-grund stapel" style={{ maxWidth: '36rem', margin: '2rem auto', textAlign: 'center' }}>
|
||||
<p style={{ fontSize: '2.2rem', margin: 0 }} aria-hidden="true">
|
||||
🔍
|
||||
</p>
|
||||
<h1 style={{ marginBottom: '0.2rem' }}>Nicht gefunden</h1>
|
||||
<p className="leise">
|
||||
Diese Seite gibt es nicht – oder sie gehört nicht zu deinem Bereich.
|
||||
</p>
|
||||
<div className="reihe" style={{ justifyContent: 'center' }}>
|
||||
<Link href="/" className="knopf">
|
||||
Zur Startseite
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +1,221 @@
|
||||
import Link from 'next/link';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { ausbildungNachAzubi, ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
|
||||
import { alsDatumText, kalenderwocheText, heute } from '@/lib/zeit';
|
||||
import { Fortschrittsbalken } from '@/komponenten/Fortschrittsbalken';
|
||||
import { aktuelleWochensicht, kurzstand } from '@/lib/planung/sicht';
|
||||
import { alsDatumText, alsZeitpunktText, heute, kalenderwocheText } from '@/lib/zeit';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { wartetAufAbnahme } from '@/lib/repos/aufgaben';
|
||||
import { wartetAufAbzeichnung } from '@/lib/repos/berichtsheft';
|
||||
import { wartetAufKorrektur } from '@/lib/repos/kontrollen';
|
||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||
import { WochenAnsicht } from './WochenAnsicht';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Vorläufige Startseite. Ab Baustein 3 steht hier der Wochenplan.
|
||||
*/
|
||||
export default async function StartSeite() {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
|
||||
if (benutzer.rolle === 'ausbilder') {
|
||||
const meine = ausbildungenVonAusbilder(benutzer.id);
|
||||
return benutzer.rolle === 'ausbilder' ? (
|
||||
<AusbilderUeberblick ausbilderId={benutzer.id} name={benutzer.name} />
|
||||
) : (
|
||||
<AzubiWoche azubiId={benutzer.id} name={benutzer.name} />
|
||||
);
|
||||
}
|
||||
|
||||
// --- Azubi: der Wochenplan ist die Startseite ------------------------------
|
||||
|
||||
function AzubiWoche({ azubiId, name }: { azubiId: string; name: string }) {
|
||||
const ausbildung = ausbildungNachAzubi(azubiId);
|
||||
|
||||
if (!ausbildung) {
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Guten Tag, {benutzer.name.split(' ')[0]}</h1>
|
||||
<p className="leise">
|
||||
{kalenderwocheText(heute())} · {alsDatumText(heute())}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="karte stapel">
|
||||
<h2 style={{ marginBottom: 0 }}>Ausbildungen</h2>
|
||||
{meine.length === 0 ? (
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
|
||||
{meine.map(({ ausbildung, azubi }) => (
|
||||
<li key={ausbildung.id}>
|
||||
<strong>{azubi.name}</strong> · seit {alsDatumText(ausbildung.beginn)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<h1>Guten Tag, {name.split(' ')[0]}</h1>
|
||||
<p className="hinweis hinweis-warnung">
|
||||
Zu deinem Konto ist noch keine Ausbildung hinterlegt. Sag deinem Ausbilder Bescheid – ohne
|
||||
Ausbildungsverhältnis gibt es keinen Wochenplan.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ausbildung = ausbildungNachAzubi(benutzer.id);
|
||||
const sicht = aktuelleWochensicht(ausbildung.id, azubiId);
|
||||
|
||||
if (!sicht) {
|
||||
return (
|
||||
<div className="stapel">
|
||||
<h1>Guten Tag, {name.split(' ')[0]}</h1>
|
||||
<p className="hinweis hinweis-warnung">
|
||||
Für deine Ausbildung ist noch kein Wochenplan angelegt. Dein Ausbilder kann ihn unter „Wochenplan“
|
||||
erzeugen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <WochenAnsicht sicht={sicht} istAzubi wochenLink="/woche/{nr}" />;
|
||||
}
|
||||
|
||||
// --- Ausbilder: wer steht wo -----------------------------------------------
|
||||
|
||||
function AusbilderUeberblick({ ausbilderId, name }: { ausbilderId: string; name: string }) {
|
||||
const meine = ausbildungenVonAusbilder(ausbilderId);
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Guten Tag, {benutzer.name.split(' ')[0]}</h1>
|
||||
<h1>Guten Tag, {name.split(' ')[0]}</h1>
|
||||
<p className="leise">
|
||||
{kalenderwocheText(heute())} · {alsDatumText(heute())}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="karte">
|
||||
{ausbildung ? (
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
Deine Ausbildung läuft seit {alsDatumText(ausbildung.beginn)}
|
||||
{ausbildung.probezeitBis ? ` (Probezeit bis ${alsDatumText(ausbildung.probezeitBis)})` : ''}.
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
Zu deinem Konto ist noch keine Ausbildung hinterlegt. Sag deinem Ausbilder Bescheid.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
{meine.length === 0 ? (
|
||||
<p className="hinweis hinweis-warnung">
|
||||
Noch kein Azubikonto angelegt. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
|
||||
</p>
|
||||
) : (
|
||||
<section className="stapel" aria-labelledby="stand">
|
||||
<h2 id="stand">Wo deine Auszubildenden stehen</h2>
|
||||
|
||||
{meine.map(({ ausbildung, azubi }) => {
|
||||
const stand = kurzstand(ausbildung.id, azubi.id);
|
||||
|
||||
return (
|
||||
<article key={ausbildung.id} className="karte stapel">
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '0.1rem' }}>{azubi.name}</h3>
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Ausbildung seit {alsDatumText(ausbildung.beginn)}
|
||||
{azubi.aktiv === 1 ? '' : ' · Konto gesperrt'}
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/wochenplan?azubi=${azubi.id}`} className="knopf knopf-zweit">
|
||||
Wochenplan
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{stand ? (
|
||||
<>
|
||||
<div className="karte-grund stapel-eng">
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>
|
||||
Woche {stand.wochenNummer} · {stand.titel}
|
||||
</strong>
|
||||
<span className="leise">
|
||||
{alsDatumText(stand.geplantVon)} – {alsDatumText(stand.geplantBis)}
|
||||
</span>
|
||||
</div>
|
||||
<Fortschrittsbalken fortschritt={stand.fortschritt} beschriftung="Stand der Woche" />
|
||||
</div>
|
||||
|
||||
{stand.fortschritt.offen.length > 0 ? (
|
||||
<ul className="offen-liste">
|
||||
{stand.fortschritt.offen.map((bereich) => (
|
||||
<li key={bereich.schluessel}>
|
||||
<span>{bereich.titel}</span>
|
||||
<span className="leise">
|
||||
{bereich.zaehlwerk.erledigt} von {bereich.zaehlwerk.gesamt}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<Link href={`/woche/${stand.wochenNummer}?azubi=${azubi.id}`}>
|
||||
Die Woche im Einzelnen ansehen →
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
|
||||
Für {azubi.name} ist noch kein Wochenplan angelegt.{' '}
|
||||
<Link href={`/wochenplan?azubi=${azubi.id}`}>Jetzt erzeugen</Link>.
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<WartetAufMich ausbilderId={ausbilderId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Was auf den Ausbilder wartet ------------------------------------------
|
||||
|
||||
function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
|
||||
const meine = ausbildungenVonAusbilder(ausbilderId);
|
||||
const alle = inhalte();
|
||||
const azubiIds = meine.map(({ azubi }) => azubi.id);
|
||||
const abnahmen = wartetAufAbnahme(azubiIds);
|
||||
const berichte = wartetAufAbzeichnung(azubiIds);
|
||||
const kontrollen = wartetAufKorrektur(azubiIds);
|
||||
const gesamt = abnahmen.length + berichte.length + kontrollen.length;
|
||||
|
||||
return (
|
||||
<section className="karte stapel-eng" aria-labelledby="wartet">
|
||||
<h2 id="wartet" style={{ fontSize: '1rem', marginBottom: 0 }}>
|
||||
Was auf dich wartet
|
||||
</h2>
|
||||
|
||||
{gesamt === 0 ? (
|
||||
<p className="leise" style={{ marginBottom: 0 }}>
|
||||
Nichts offen – keine Abnahme, keine Korrektur, kein Berichtsheft.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="offen-liste">
|
||||
{kontrollen.map((stand) => {
|
||||
const kontrolle = alle.kontrolleNach.get(stand.kontrolleId);
|
||||
const azubi = benutzerNachId(stand.benutzerId);
|
||||
return (
|
||||
<li key={stand.id}>
|
||||
<span>
|
||||
<Link href={`/kontrollen/${stand.kontrolleId}?azubi=${stand.benutzerId}`}>
|
||||
<strong>Lernzielkontrolle</strong> {kontrolle?.titel ?? stand.kontrolleId} korrigieren
|
||||
</Link>
|
||||
{azubi ? <span className="leise"> · {azubi.name}</span> : null}
|
||||
</span>
|
||||
<span className="leise">abgegeben {alsZeitpunktText(stand.abgegebenAm)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{berichte.map((bericht) => {
|
||||
const azubi = benutzerNachId(bericht.benutzerId);
|
||||
return (
|
||||
<li key={bericht.id}>
|
||||
<span>
|
||||
<Link href={`/berichtsheft/${bericht.id}`}>
|
||||
<strong>Berichtsheft Nr. {bericht.nr}</strong> abzeichnen
|
||||
</Link>
|
||||
{azubi ? <span className="leise"> · {azubi.name}</span> : null}
|
||||
</span>
|
||||
<span className="leise">eingereicht {alsZeitpunktText(bericht.eingereichtAm)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{abnahmen.map((stand) => {
|
||||
const aufgabe = alle.aufgabeNach.get(stand.aufgabeId);
|
||||
const azubi = benutzerNachId(stand.benutzerId);
|
||||
return (
|
||||
<li key={stand.id}>
|
||||
<span>
|
||||
<Link href={`/aufgaben/${stand.aufgabeId}?azubi=${stand.benutzerId}`}>
|
||||
<strong>{stand.aufgabeId}</strong> {aufgabe?.titel ?? 'Aufgabe'}
|
||||
</Link>
|
||||
{azubi ? <span className="leise"> · {azubi.name}</span> : null}
|
||||
</span>
|
||||
<span className="leise">abgegeben {alsZeitpunktText(stand.abgegebenAm)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { passwortMangel } from '@/lib/auth/passwort';
|
||||
import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer';
|
||||
import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
|
||||
import { alsDatumText } from '@/lib/zeit';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { planAuffrischen } from '@/lib/repos/wochenplanung';
|
||||
|
||||
export type Rueckmeldung = { fehler?: string; erfolg?: string };
|
||||
|
||||
@@ -31,7 +33,7 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P
|
||||
if (benutzerNachEmail(email)) return { fehler: 'Diese E-Mail-Adresse ist schon vergeben.' };
|
||||
|
||||
const azubi = await benutzerAnlegen({ name, email, passwort, rolle: 'azubi' });
|
||||
ausbildungAnlegen({
|
||||
const neueAusbildung = ausbildungAnlegen({
|
||||
azubiId: azubi.id,
|
||||
ausbilderId: ausbilder.id,
|
||||
beginn,
|
||||
@@ -39,8 +41,17 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P
|
||||
ende: datumMuster.test(ende) ? ende : null,
|
||||
});
|
||||
|
||||
// Der Wochenplan entsteht gleich mit – ohne ihn ist die Startseite leer.
|
||||
const wochen = inhalte().wochen.map((w) => w.nummer);
|
||||
const eingeplant = planAuffrischen(neueAusbildung.id, beginn, wochen);
|
||||
|
||||
revalidatePath('/verwaltung');
|
||||
return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${alsDatumText(beginn)} eingerichtet.` };
|
||||
revalidatePath('/');
|
||||
return {
|
||||
erfolg:
|
||||
`Konto für ${name} angelegt und Ausbildung ab ${alsDatumText(beginn)} eingerichtet. ` +
|
||||
`${eingeplant} ${eingeplant === 1 ? 'Woche' : 'Wochen'} eingeplant.`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { erfordereBenutzer, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
|
||||
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
|
||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||
import { wochensicht } from '@/lib/planung/sicht';
|
||||
import { WochenAnsicht } from '../../WochenAnsicht';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ nummer: string }> }): Promise<Metadata> {
|
||||
const { nummer } = await params;
|
||||
return { title: `Woche ${nummer}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine bestimmte Woche. Der Azubi sieht nur seine eigene; der Ausbilder gibt
|
||||
* über `?azubi=` an, wessen Stand er sehen will – geprüft wird das serverseitig.
|
||||
*/
|
||||
export default async function WochenSeite({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ nummer: string }>;
|
||||
searchParams: Promise<{ azubi?: string }>;
|
||||
}) {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const { nummer } = await params;
|
||||
const { azubi: azubiParameter } = await searchParams;
|
||||
|
||||
const wochenNummer = Number(nummer);
|
||||
if (!Number.isInteger(wochenNummer) || wochenNummer < 1) notFound();
|
||||
|
||||
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (azubiParameter ?? '');
|
||||
if (!azubiId) notFound();
|
||||
|
||||
// Der Kern: Ob dieser Betrachter diesen Azubi sehen darf, entscheidet der
|
||||
// Server – nicht die Oberfläche und nicht die Adresszeile.
|
||||
erfordereSichtAufAzubi(benutzer, azubiId);
|
||||
|
||||
const ausbildung = ausbildungNachAzubi(azubiId);
|
||||
if (!ausbildung) notFound();
|
||||
|
||||
const sicht = wochensicht(ausbildung.id, azubiId, wochenNummer);
|
||||
if (!sicht) notFound();
|
||||
|
||||
const istAzubi = benutzer.rolle === 'azubi';
|
||||
const azubi = istAzubi ? null : benutzerNachId(azubiId);
|
||||
const anhang = istAzubi ? '' : `?azubi=${azubiId}`;
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
{azubi ? (
|
||||
<nav className="leise nicht-drucken" aria-label="Wo du bist">
|
||||
<Link href="/">Überblick</Link>
|
||||
{' › '}
|
||||
<Link href={`/wochenplan?azubi=${azubiId}`}>Wochenplan {azubi.name}</Link>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<WochenAnsicht sicht={sicht} istAzubi={istAzubi} wochenLink={`/woche/{nr}${anhang}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
|
||||
import { planErzeugen, wochenNotiz, wocheVerlegen, wochenStatusSetzen, type Rueckmeldung } from './aktionen';
|
||||
|
||||
export function PlanErzeugenFormular({ azubiId, text }: { azubiId: string; text: string }) {
|
||||
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(planErzeugen, {});
|
||||
|
||||
return (
|
||||
<form action={aktion} className="stapel-eng">
|
||||
<Meldung zustand={zustand} />
|
||||
<input type="hidden" name="azubiId" value={azubiId} />
|
||||
<Absenden className="knopf knopf-akzent">{text}</Absenden>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerlegenFormular({
|
||||
azubiId,
|
||||
wochen,
|
||||
}: {
|
||||
azubiId: string;
|
||||
wochen: { nummer: number; titel: string; geplantVon: string }[];
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(wocheVerlegen, {});
|
||||
const erste = wochen[0];
|
||||
|
||||
return (
|
||||
<form action={aktion} className="stapel">
|
||||
<Meldung zustand={zustand} />
|
||||
<input type="hidden" name="azubiId" value={azubiId} />
|
||||
|
||||
<div className="reihe" style={{ alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: '2 1 240px' }}>
|
||||
<label htmlFor="verlegen-woche">Woche</label>
|
||||
<select id="verlegen-woche" name="wochenNummer" defaultValue={erste?.nummer}>
|
||||
{wochen.map((w) => (
|
||||
<option key={w.nummer} value={w.nummer}>
|
||||
Woche {w.nummer} – {w.titel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: '1 1 170px' }}>
|
||||
<label htmlFor="verlegen-datum">Neuer Beginn</label>
|
||||
<input id="verlegen-datum" name="neuerMontag" type="date" defaultValue={erste?.geplantVon} required />
|
||||
</div>
|
||||
|
||||
<Absenden className="knopf">Verlegen</Absenden>
|
||||
</div>
|
||||
|
||||
<label className="ankreuzfeld">
|
||||
<input type="checkbox" name="folgende" value="an" defaultChecked />
|
||||
<span>
|
||||
Folgende Wochen mitverschieben
|
||||
<span className="leise" style={{ display: 'block' }}>
|
||||
Der Normalfall: Fällt eine Woche aus, rutscht der ganze Rest hinterher. Ohne Haken wird nur diese
|
||||
eine Woche verlegt – dann können sich Termine überlappen.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Ein beliebiges Datum wird auf den Montag seiner Woche gerundet. Abgeschlossene Wochen bleiben stehen.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusFormular({
|
||||
azubiId,
|
||||
wochenNummer,
|
||||
status,
|
||||
}: {
|
||||
azubiId: string;
|
||||
wochenNummer: number;
|
||||
status: 'geplant' | 'laufend' | 'abgeschlossen';
|
||||
}) {
|
||||
return (
|
||||
<form action={wochenStatusSetzen} className="reihe" style={{ gap: '0.35rem' }}>
|
||||
<input type="hidden" name="azubiId" value={azubiId} />
|
||||
<input type="hidden" name="wochenNummer" value={wochenNummer} />
|
||||
<button
|
||||
type="submit"
|
||||
name="status"
|
||||
value="laufend"
|
||||
className="knopf knopf-still"
|
||||
disabled={status === 'laufend'}
|
||||
>
|
||||
Startet jetzt
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
name="status"
|
||||
value="abgeschlossen"
|
||||
className="knopf knopf-still"
|
||||
disabled={status === 'abgeschlossen'}
|
||||
>
|
||||
Abschließen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
name="status"
|
||||
value="geplant"
|
||||
className="knopf knopf-still"
|
||||
disabled={status === 'geplant'}
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotizFormular({
|
||||
azubiId,
|
||||
wochenNummer,
|
||||
notiz,
|
||||
}: {
|
||||
azubiId: string;
|
||||
wochenNummer: number;
|
||||
notiz: string;
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(wochenNotiz, {});
|
||||
|
||||
return (
|
||||
<form action={aktion} className="stapel-eng">
|
||||
<Meldung zustand={zustand} />
|
||||
<input type="hidden" name="azubiId" value={azubiId} />
|
||||
<input type="hidden" name="wochenNummer" value={wochenNummer} />
|
||||
<label htmlFor={`notiz-${wochenNummer}`}>Notiz zu dieser Woche (sieht der Azubi)</label>
|
||||
<textarea
|
||||
id={`notiz-${wochenNummer}`}
|
||||
name="notiz"
|
||||
defaultValue={notiz}
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
placeholder="z. B. Dienstag Berufsschule, Mittwoch Kundeneinsatz Halle 3"
|
||||
/>
|
||||
<div>
|
||||
<Absenden className="knopf knopf-zweit">Notiz speichern</Absenden>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { wocheSchieben } from './aktionen';
|
||||
|
||||
export type Planeintrag = {
|
||||
wochenNummer: number;
|
||||
titel: string;
|
||||
geplantVon: string;
|
||||
geplantBis: string;
|
||||
geplantVonText: string;
|
||||
geplantBisText: string;
|
||||
kalenderwoche: string;
|
||||
tatsaechlichVonText: string | null;
|
||||
status: 'geplant' | 'laufend' | 'abgeschlossen';
|
||||
notiz: string | null;
|
||||
handouts: number;
|
||||
aufgaben: number;
|
||||
hatKontrolle: boolean;
|
||||
};
|
||||
|
||||
const statusMarke: Record<Planeintrag['status'], { text: string; ton: string; zeichen: string }> = {
|
||||
geplant: { text: 'geplant', ton: 'neutral', zeichen: '○' },
|
||||
laufend: { text: 'läuft', ton: 'warnung', zeichen: '▶' },
|
||||
abgeschlossen: { text: 'abgeschlossen', ton: 'gut', zeichen: '✓' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Der Wochenplan zum Umsortieren.
|
||||
*
|
||||
* Ziehen und Ablegen mit der Maus, und – genauso wichtig – mit der Tastatur:
|
||||
* Eintrag anwählen, mit den Pfeiltasten verschieben. Reines Ziehen wäre für
|
||||
* Tastaturbedienung eine Sackgasse.
|
||||
*/
|
||||
export function PlanListe({ eintraege, azubiId }: { eintraege: Planeintrag[]; azubiId: string }) {
|
||||
const [gezogen, setzeGezogen] = useState<number | null>(null);
|
||||
const [ueber, setzeUeber] = useState<number | null>(null);
|
||||
const [laeuft, starte] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
function verschiebeAn(wochenNummer: number, neueStelle: number) {
|
||||
if (neueStelle < 0 || neueStelle >= eintraege.length) return;
|
||||
|
||||
const daten = new FormData();
|
||||
daten.set('azubiId', azubiId);
|
||||
daten.set('wochenNummer', String(wochenNummer));
|
||||
daten.set('neueStelle', String(neueStelle));
|
||||
|
||||
starte(async () => {
|
||||
await wocheSchieben(daten);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function beiTaste(ereignis: React.KeyboardEvent, wochenNummer: number, stelle: number) {
|
||||
if (ereignis.key === 'ArrowUp' && (ereignis.altKey || ereignis.ctrlKey)) {
|
||||
ereignis.preventDefault();
|
||||
verschiebeAn(wochenNummer, stelle - 1);
|
||||
}
|
||||
if (ereignis.key === 'ArrowDown' && (ereignis.altKey || ereignis.ctrlKey)) {
|
||||
ereignis.preventDefault();
|
||||
verschiebeAn(wochenNummer, stelle + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className={`planliste${laeuft ? ' planliste-wartet' : ''}`}>
|
||||
{eintraege.map((eintrag, stelle) => {
|
||||
const marke = statusMarke[eintrag.status];
|
||||
const wirdGezogen = gezogen === eintrag.wochenNummer;
|
||||
const istZiel = ueber === stelle && gezogen !== null && !wirdGezogen;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={eintrag.wochenNummer}
|
||||
className={`planzeile${wirdGezogen ? ' planzeile-gezogen' : ''}${istZiel ? ' planzeile-ziel' : ''}`}
|
||||
draggable
|
||||
onDragStart={() => setzeGezogen(eintrag.wochenNummer)}
|
||||
onDragEnd={() => {
|
||||
setzeGezogen(null);
|
||||
setzeUeber(null);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setzeUeber(stelle);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (gezogen !== null && gezogen !== eintrag.wochenNummer) verschiebeAn(gezogen, stelle);
|
||||
setzeGezogen(null);
|
||||
setzeUeber(null);
|
||||
}}
|
||||
>
|
||||
<div className="planzeile-griff" aria-hidden="true">
|
||||
⠿
|
||||
</div>
|
||||
|
||||
<div className="planzeile-inhalt">
|
||||
<div className="reihe" style={{ gap: '0.5rem' }}>
|
||||
<span className="wochen-nummer wochen-nummer-klein">Woche {eintrag.wochenNummer}</span>
|
||||
<span className="marke-status" data-ton={marke.ton}>
|
||||
{marke.zeichen} {marke.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link href={`/woche/${eintrag.wochenNummer}?azubi=${azubiId}`} className="planzeile-titel">
|
||||
{eintrag.titel}
|
||||
</Link>
|
||||
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
{eintrag.geplantVonText} – {eintrag.geplantBisText} · {eintrag.kalenderwoche}
|
||||
{eintrag.tatsaechlichVonText ? ` · tatsächlich ab ${eintrag.tatsaechlichVonText}` : ''}
|
||||
</p>
|
||||
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
{eintrag.handouts} Handouts · {eintrag.aufgaben} Aufgaben
|
||||
{eintrag.hatKontrolle ? ' · Lernzielkontrolle' : ''}
|
||||
</p>
|
||||
|
||||
{eintrag.notiz ? (
|
||||
<p className="planzeile-notiz">
|
||||
<strong>Notiz:</strong> {eintrag.notiz}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="planzeile-knoepfe">
|
||||
<button
|
||||
type="button"
|
||||
className="knopf knopf-still"
|
||||
onClick={() => verschiebeAn(eintrag.wochenNummer, stelle - 1)}
|
||||
onKeyDown={(e) => beiTaste(e, eintrag.wochenNummer, stelle)}
|
||||
disabled={stelle === 0 || laeuft}
|
||||
title="Eine Woche früher (auch mit Alt + Pfeil hoch)"
|
||||
>
|
||||
<span aria-hidden="true">↑</span>
|
||||
<span className="nur-lesehilfe">Woche {eintrag.wochenNummer} nach oben</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="knopf knopf-still"
|
||||
onClick={() => verschiebeAn(eintrag.wochenNummer, stelle + 1)}
|
||||
onKeyDown={(e) => beiTaste(e, eintrag.wochenNummer, stelle)}
|
||||
disabled={stelle === eintraege.length - 1 || laeuft}
|
||||
title="Eine Woche später (auch mit Alt + Pfeil runter)"
|
||||
>
|
||||
<span aria-hidden="true">↓</span>
|
||||
<span className="nur-lesehilfe">Woche {eintrag.wochenNummer} nach unten</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { erfordereAusbilder, erfordereBewertungsrecht } from '@/lib/auth/rechte';
|
||||
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import {
|
||||
notizSetzen,
|
||||
planAuffrischen,
|
||||
statusSetzen,
|
||||
wocheUmsortieren,
|
||||
wocheVerschieben,
|
||||
} from '@/lib/repos/wochenplanung';
|
||||
import { heute } from '@/lib/zeit';
|
||||
import { wochenStatus, type WochenStatus } from '@/lib/db/schema';
|
||||
|
||||
export type Rueckmeldung = { fehler?: string; erfolg?: string };
|
||||
|
||||
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Alle Aktionen hier prüfen zuerst, ob dieser Ausbilder für diesen Azubi
|
||||
* zuständig ist. Die Azubikennung kommt aus dem Formular und ist damit
|
||||
* grundsätzlich manipulierbar – deshalb wird sie nie geglaubt.
|
||||
*/
|
||||
async function zustaendigeAusbildung(azubiId: string) {
|
||||
const ausbilder = await erfordereAusbilder();
|
||||
erfordereBewertungsrecht(ausbilder, azubiId);
|
||||
const ausbildung = ausbildungNachAzubi(azubiId);
|
||||
if (!ausbildung) throw new Error('Zu diesem Konto gibt es keine Ausbildung.');
|
||||
return ausbildung;
|
||||
}
|
||||
|
||||
/** Alle Seiten auffrischen, auf denen die Planung sichtbar ist. */
|
||||
function aktualisiere() {
|
||||
revalidatePath('/wochenplan');
|
||||
revalidatePath('/');
|
||||
revalidatePath('/woche/[nummer]', 'page');
|
||||
}
|
||||
|
||||
export async function planErzeugen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const azubiId = String(formular.get('azubiId') ?? '');
|
||||
const ausbildung = await zustaendigeAusbildung(azubiId);
|
||||
|
||||
const nummern = inhalte().wochen.map((w) => w.nummer);
|
||||
const angelegt = planAuffrischen(ausbildung.id, ausbildung.beginn, nummern);
|
||||
|
||||
aktualisiere();
|
||||
return angelegt === 0
|
||||
? { erfolg: 'Der Plan war schon vollständig – es gab nichts nachzutragen.' }
|
||||
: { erfolg: `${angelegt} ${angelegt === 1 ? 'Woche' : 'Wochen'} eingeplant.` };
|
||||
}
|
||||
|
||||
export async function wocheVerlegen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const azubiId = String(formular.get('azubiId') ?? '');
|
||||
const ausbildung = await zustaendigeAusbildung(azubiId);
|
||||
|
||||
const wochenNummer = Number(formular.get('wochenNummer'));
|
||||
const neuerMontag = String(formular.get('neuerMontag') ?? '').trim();
|
||||
const folgende = String(formular.get('folgende') ?? '') === 'an';
|
||||
|
||||
if (!Number.isInteger(wochenNummer)) return { fehler: 'Wochennummer fehlt.' };
|
||||
if (!datumMuster.test(neuerMontag)) return { fehler: 'Bitte ein gültiges Datum wählen.' };
|
||||
|
||||
const geaendert = wocheVerschieben(ausbildung.id, wochenNummer, neuerMontag, folgende);
|
||||
|
||||
aktualisiere();
|
||||
if (geaendert === 0) return { erfolg: 'Der Termin stand schon so.' };
|
||||
return {
|
||||
erfolg: folgende
|
||||
? `Woche ${wochenNummer} verlegt, ${geaendert - 1} folgende ${geaendert - 1 === 1 ? 'Woche' : 'Wochen'} mitverschoben.`
|
||||
: `Woche ${wochenNummer} verlegt.`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function wocheSchieben(formular: FormData): Promise<void> {
|
||||
const azubiId = String(formular.get('azubiId') ?? '');
|
||||
const ausbildung = await zustaendigeAusbildung(azubiId);
|
||||
|
||||
const wochenNummer = Number(formular.get('wochenNummer'));
|
||||
const neueStelle = Number(formular.get('neueStelle'));
|
||||
if (!Number.isInteger(wochenNummer) || !Number.isInteger(neueStelle)) return;
|
||||
|
||||
wocheUmsortieren(ausbildung.id, wochenNummer, neueStelle);
|
||||
aktualisiere();
|
||||
}
|
||||
|
||||
export async function wochenStatusSetzen(formular: FormData): Promise<void> {
|
||||
const azubiId = String(formular.get('azubiId') ?? '');
|
||||
const ausbildung = await zustaendigeAusbildung(azubiId);
|
||||
|
||||
const wochenNummer = Number(formular.get('wochenNummer'));
|
||||
const status = String(formular.get('status') ?? '') as WochenStatus;
|
||||
if (!Number.isInteger(wochenNummer) || !wochenStatus.includes(status)) return;
|
||||
|
||||
statusSetzen(ausbildung.id, wochenNummer, status, heute());
|
||||
aktualisiere();
|
||||
}
|
||||
|
||||
export async function wochenNotiz(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
const azubiId = String(formular.get('azubiId') ?? '');
|
||||
const ausbildung = await zustaendigeAusbildung(azubiId);
|
||||
|
||||
const wochenNummer = Number(formular.get('wochenNummer'));
|
||||
const notiz = String(formular.get('notiz') ?? '');
|
||||
if (!Number.isInteger(wochenNummer)) return { fehler: 'Wochennummer fehlt.' };
|
||||
if (notiz.length > 500) return { fehler: 'Die Notiz ist zu lang – höchstens 500 Zeichen.' };
|
||||
|
||||
notizSetzen(ausbildung.id, wochenNummer, notiz);
|
||||
aktualisiere();
|
||||
return { erfolg: notiz.trim() ? 'Notiz gespeichert.' : 'Notiz entfernt.' };
|
||||
}
|
||||