Baustein 9: Auswertung, Rahmenplanabdeckung, Feedbackgespraech
Die Abdeckung des Ausbildungsrahmenplans ist die Ansicht, die gegenueber
der IHK zaehlt. Sie unterscheidet vier Lagen statt "erledigt/offen":
vermittelt eine abgenommene Aufgabe oder ein abgeschlossener
Checklisten-Durchlauf, oder zwei gelesene Handouts
angefangen etwas erledigt, aber noch nicht genug
geplant Inhalt vorhanden, nichts davon bearbeitet
ohne Inhalt zu dieser Position gibt es im Lehrgang gar nichts
Die vierte Lage ist die wichtigste: Sie ist keine Luecke beim Azubi,
sondern eine im Lehrgang, und wird getrennt ausgewiesen. Die Regel steht
als reine Funktion in src/lib/auswertung/abdeckung.ts, mit 17 Tests.
Das Feedbackgespraech laesst beide Seiten getrennt bewerten. Wo sie um
zwei Stufen oder mehr auseinanderliegen, wird das hervorgehoben - in der
Ansicht, in der Uebersicht und im PDF, alle drei ueber dieselbe Funktion
liegtWeitAuseinander(). Nach beidseitiger Bestaetigung ist das Gespraech
unveraenderlich und wird nur noch gelesen.
Durchstich in scripts/pruefungen/baustein-9.mjs: alle fuenf Luecken
erkannt, getrennte Spalten, Sperre serverseitig, PDF nur mit Anmeldung,
ein fremder Azubi bekommt 404 - auch auf das PDF.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -26,6 +26,51 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [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.
|
||||
@@ -356,6 +401,7 @@ anmelden und verwalten.
|
||||
- `npm run konto:anlegen` für das erste Ausbilderkonto
|
||||
|
||||
[Unveröffentlicht]: #unveröffentlicht
|
||||
[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
|
||||
|
||||
@@ -7,6 +7,53 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
@@ -352,3 +352,61 @@ 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.
|
||||
|
||||
@@ -9,9 +9,9 @@ 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:** Bausteine 1 bis 8 von 10 sind fertig – Fundament, Inhalte,
|
||||
> Wochenplan, Merkkarten, Praxisaufgaben, Checklisten, Berichtsheft und
|
||||
> Lernzielkontrollen. Der [Fahrplan](ROADMAP.md) zeigt, was folgt, das
|
||||
> **Stand:** Bausteine 1 bis 9 von 10 sind fertig. Es fehlt nur noch der
|
||||
> Feinschliff: PWA, Offlinebetrieb, Druckansichten und die Anleitung mit
|
||||
> Screenshots. Der [Fahrplan](ROADMAP.md) zeigt, was folgt, das
|
||||
> [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
|
||||
|
||||
---
|
||||
@@ -104,6 +104,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
|
||||
```
|
||||
|
||||
|
||||
@@ -93,7 +93,15 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
|
||||
- [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
|
||||
- [ ] **B9 · Auswertung, Rahmenplanabdeckung, Feedbackgespräch**
|
||||
- [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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 278 KiB |
@@ -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,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);
|
||||
@@ -87,6 +87,28 @@ async function main() {
|
||||
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 ---
|
||||
@@ -134,6 +156,10 @@ async function main() {
|
||||
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();
|
||||
@@ -141,6 +167,8 @@ async function main() {
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
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';
|
||||
|
||||
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} />
|
||||
</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,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('.', ',');
|
||||
}
|
||||
@@ -14,6 +14,8 @@ const fuerAzubi: Navigationseintrag[] = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
@@ -26,6 +28,8 @@ const fuerAusbilder: Navigationseintrag[] = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
@@ -739,6 +739,13 @@ a.glossarbegriff:hover {
|
||||
}
|
||||
.offen-liste li > span:first-of-type { flex: 1; }
|
||||
|
||||
/* Steht neben einem Eintrag „bestätigt", darf davor kein offener Kreis
|
||||
stehen – das Zeichen soll den Zustand sagen, nicht ihm widersprechen. */
|
||||
.offen-liste li[data-erledigt="ja"]::before {
|
||||
content: "✓";
|
||||
color: var(--gut);
|
||||
}
|
||||
|
||||
.wochentage {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -1675,3 +1682,167 @@ a.kontrollzeile:hover { background: var(--flaeche-kraeftig); color: var(--text);
|
||||
@media print {
|
||||
.kontrolle-kopf { position: static !important; }
|
||||
}
|
||||
|
||||
/* --- Auswertung --- */
|
||||
|
||||
.abdeckungsgruppe > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.abdeckungsgruppe > summary::-webkit-details-marker { display: none; }
|
||||
.abdeckungsgruppe > summary::before { content: "▸"; color: var(--petrol); }
|
||||
.abdeckungsgruppe[open] > summary::before { content: "▾"; }
|
||||
|
||||
.abdeckungsliste { list-style: none; margin: 0.6rem 0 0; padding: 0; display: grid; gap: 0.35rem; }
|
||||
.abdeckungsliste li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: var(--radius-klein);
|
||||
background: var(--grund);
|
||||
border: 1px solid var(--rand-zart);
|
||||
border-left: 4px solid var(--rand);
|
||||
}
|
||||
.abdeckungsliste li[data-stufe="abgedeckt"] { border-left-color: var(--gut); }
|
||||
.abdeckungsliste li[data-stufe="angefangen"] { border-left-color: var(--bernstein); }
|
||||
.abdeckungsliste li[data-stufe="ohne_inhalt"] { border-left-color: var(--fehler); background: var(--fehler-flaeche); }
|
||||
|
||||
.abdeckung-kuerzel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.6rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: var(--radius-klein);
|
||||
background: var(--flaeche-kraeftig);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.abdeckung-titel { flex: 1 1 260px; font-weight: 600; line-height: 1.4; }
|
||||
|
||||
.nachweisliste { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.3rem; }
|
||||
.nachweis {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
background: var(--flaeche);
|
||||
color: var(--text-leise);
|
||||
cursor: help;
|
||||
}
|
||||
.nachweis[data-erledigt="ja"] { background: var(--gut-flaeche); color: var(--gut); }
|
||||
|
||||
.verlaufsliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.45rem; }
|
||||
.verlaufsliste li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
background: var(--flaeche);
|
||||
border: 1px solid var(--rand-zart);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6rem 0.9rem;
|
||||
}
|
||||
.verlauf-titel { flex: 1 1 200px; font-weight: 650; }
|
||||
.verlauf-balken { display: flex; align-items: center; gap: 0.5rem; flex: 0 1 180px; }
|
||||
.verlauf-balken .balken { flex: 1; }
|
||||
.note-klein { width: 2rem; height: 2rem; font-size: 1.1rem; }
|
||||
|
||||
/* --- Feedbackgespräch --- */
|
||||
|
||||
.gespraechsliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.45rem; }
|
||||
.gespraechszeile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
background: var(--flaeche);
|
||||
border: 1px solid var(--rand-zart);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.9rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.gespraechszeile:hover { background: var(--flaeche-kraeftig); color: var(--text); }
|
||||
.gespraech-inhalt { flex: 1 1 220px; font-weight: 650; }
|
||||
|
||||
.gespraech-datum {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: var(--radius-klein);
|
||||
background: var(--petrol);
|
||||
color: var(--text-auf-petrol);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gespraech-datum-gross { font-size: 0.95rem; padding: 0.25rem 0.7rem; }
|
||||
|
||||
.kriterienbogen { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.6rem; }
|
||||
.kriterienbogen > li {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
background: var(--grund);
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
.kriterienbogen > li[data-abweichung="ja"] { border-color: var(--bernstein); background: var(--warnung-flaeche); }
|
||||
|
||||
.kriterium-kopf { display: grid; gap: 0.1rem; }
|
||||
.kriterium-kopf .leise { font-size: 0.85rem; }
|
||||
|
||||
.stufenwahl {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.stufenwahl:disabled { opacity: 0.6; }
|
||||
|
||||
.stufe {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 400;
|
||||
}
|
||||
.stufe input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.stufe > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: var(--radius-klein);
|
||||
border: 2px solid var(--rand);
|
||||
background: var(--grund);
|
||||
font-weight: 700;
|
||||
}
|
||||
.stufe input:checked + span {
|
||||
border-color: var(--petrol);
|
||||
background: var(--petrol);
|
||||
color: var(--text-auf-petrol);
|
||||
}
|
||||
.stufe input:focus-visible + span { outline: 3px solid var(--bernstein); outline-offset: 2px; }
|
||||
|
||||
.kriterium-fuss {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.abdeckungsgruppe > summary { display: none; }
|
||||
.abdeckungsgruppe[open] .abdeckungsliste { margin-top: 0; }
|
||||
.stufe input:checked + span { border-color: #000; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
berechneAbdeckung,
|
||||
berechneLernfelder,
|
||||
bilanz,
|
||||
istBelastbar,
|
||||
nachGruppen,
|
||||
stufeAus,
|
||||
type Nachweis,
|
||||
type Position,
|
||||
} from './abdeckung';
|
||||
|
||||
function nachweis(art: Nachweis['art'], erledigt: boolean, id = `${art}-${erledigt}`): Nachweis {
|
||||
return { art, id, titel: `${art} ${id}`, erledigt };
|
||||
}
|
||||
|
||||
const positionen: Position[] = [
|
||||
{ id: 'A1', titel: 'Arbeitsaufgaben planen', kurz: 'Planen', gruppe: 'allgemein', verordnung: 'A, Nr. 1' },
|
||||
{ id: 'B2', titel: 'Netzwerke installieren', kurz: 'Netzwerke', gruppe: 'si', verordnung: 'C, Nr. 2' },
|
||||
{ id: 'C4', titel: 'Umweltschutz', kurz: 'Umwelt', gruppe: 'integrativ', verordnung: 'F, Nr. 4' },
|
||||
];
|
||||
|
||||
describe('Gewicht eines Nachweises', () => {
|
||||
it('hält Aufgabe und Checkliste für belastbar, ein Handout für stützend', () => {
|
||||
expect(istBelastbar('aufgabe')).toBe(true);
|
||||
expect(istBelastbar('checkliste')).toBe(true);
|
||||
expect(istBelastbar('handout')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Abdeckungsstufe', () => {
|
||||
it('meldet eine Position ohne jeden Inhalt als Lücke im Lehrgang', () => {
|
||||
expect(stufeAus([])).toBe('ohne_inhalt');
|
||||
});
|
||||
|
||||
it('meldet geplanten, aber unbearbeiteten Inhalt als offen', () => {
|
||||
expect(stufeAus([nachweis('handout', false), nachweis('aufgabe', false)])).toBe('offen');
|
||||
});
|
||||
|
||||
it('lässt ein einzelnes gelesenes Handout nicht als vermittelt gelten', () => {
|
||||
expect(stufeAus([nachweis('handout', true)])).toBe('angefangen');
|
||||
});
|
||||
|
||||
it('lässt zwei gelesene Handouts als vermittelt gelten', () => {
|
||||
expect(stufeAus([nachweis('handout', true, 'h1'), nachweis('handout', true, 'h2')])).toBe('abgedeckt');
|
||||
});
|
||||
|
||||
it('lässt eine abgenommene Aufgabe allein als vermittelt gelten', () => {
|
||||
expect(stufeAus([nachweis('aufgabe', true)])).toBe('abgedeckt');
|
||||
});
|
||||
|
||||
it('lässt eine abgeschlossene Checkliste allein als vermittelt gelten', () => {
|
||||
expect(stufeAus([nachweis('checkliste', true)])).toBe('abgedeckt');
|
||||
});
|
||||
|
||||
it('zählt unerledigte Nachweise nicht mit', () => {
|
||||
expect(stufeAus([nachweis('aufgabe', false), nachweis('handout', true)])).toBe('angefangen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Abdeckung berechnen', () => {
|
||||
it('ordnet jeder Position ihre Nachweise zu', () => {
|
||||
const abdeckung = berechneAbdeckung(
|
||||
positionen,
|
||||
new Map([
|
||||
['A1', [nachweis('aufgabe', true), nachweis('handout', true)]],
|
||||
['B2', [nachweis('handout', false)]],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(abdeckung[0]!.stufe).toBe('abgedeckt');
|
||||
expect(abdeckung[0]!.belastbare).toBe(1);
|
||||
expect(abdeckung[0]!.stuetzende).toBe(1);
|
||||
expect(abdeckung[0]!.geplant).toBe(2);
|
||||
|
||||
expect(abdeckung[1]!.stufe).toBe('offen');
|
||||
expect(abdeckung[2]!.stufe).toBe('ohne_inhalt');
|
||||
});
|
||||
|
||||
it('behält die Reihenfolge der Positionen bei', () => {
|
||||
const abdeckung = berechneAbdeckung(positionen, new Map());
|
||||
expect(abdeckung.map((a) => a.id)).toEqual(['A1', 'B2', 'C4']);
|
||||
});
|
||||
|
||||
it('übernimmt die Fundstelle in der Verordnung – sie muss nachlesbar bleiben', () => {
|
||||
const abdeckung = berechneAbdeckung(positionen, new Map());
|
||||
expect(abdeckung[1]!.verordnung).toBe('C, Nr. 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bilanz', () => {
|
||||
it('zählt die vier Lagen getrennt', () => {
|
||||
const abdeckung = berechneAbdeckung(
|
||||
positionen,
|
||||
new Map([
|
||||
['A1', [nachweis('aufgabe', true)]],
|
||||
['B2', [nachweis('handout', true)]],
|
||||
]),
|
||||
);
|
||||
const summe = bilanz(abdeckung);
|
||||
|
||||
expect(summe.gesamt).toBe(3);
|
||||
expect(summe.abgedeckt).toBe(1);
|
||||
expect(summe.angefangen).toBe(1);
|
||||
expect(summe.ohneInhalt).toBe(1);
|
||||
expect(summe.offen).toBe(0);
|
||||
expect(summe.prozent).toBe(33);
|
||||
});
|
||||
|
||||
it('rechnet nur Vermitteltes in den Prozentsatz – angefangen zählt nicht', () => {
|
||||
const abdeckung = berechneAbdeckung(
|
||||
positionen,
|
||||
new Map([
|
||||
['A1', [nachweis('handout', true)]],
|
||||
['B2', [nachweis('handout', true)]],
|
||||
['C4', [nachweis('handout', true)]],
|
||||
]),
|
||||
);
|
||||
expect(bilanz(abdeckung).prozent).toBe(0);
|
||||
});
|
||||
|
||||
it('kommt ohne Positionen zurecht', () => {
|
||||
expect(bilanz([]).prozent).toBe(0);
|
||||
expect(bilanz([]).gesamt).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nach Gruppen', () => {
|
||||
it('fasst die Abschnitte des Rahmenplans getrennt zusammen', () => {
|
||||
const abdeckung = berechneAbdeckung(positionen, new Map([['A1', [nachweis('aufgabe', true)]]]));
|
||||
const gruppen = nachGruppen(abdeckung);
|
||||
|
||||
expect(gruppen.map((g) => g.gruppe)).toEqual(['allgemein', 'si', 'integrativ']);
|
||||
expect(gruppen[0]!.bilanz.abgedeckt).toBe(1);
|
||||
expect(gruppen[1]!.bilanz.abgedeckt).toBe(0);
|
||||
});
|
||||
|
||||
it('behält die Reihenfolge des ersten Auftretens', () => {
|
||||
const gedreht = [...positionen].reverse();
|
||||
expect(nachGruppen(berechneAbdeckung(gedreht, new Map())).map((g) => g.gruppe)).toEqual([
|
||||
'integrativ',
|
||||
'si',
|
||||
'allgemein',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lernfelder', () => {
|
||||
const lernfelder = [
|
||||
{ id: 'LF1', titel: 'Das Unternehmen beschreiben', jahr: 1, stunden: 40 },
|
||||
{ id: 'LF3', titel: 'Clients in Netzwerke einbinden', jahr: 1, stunden: 80 },
|
||||
];
|
||||
|
||||
it('bewertet sie nach derselben Regel', () => {
|
||||
const abdeckung = berechneLernfelder(lernfelder, new Map([['LF3', [nachweis('aufgabe', true)]]]));
|
||||
|
||||
expect(abdeckung[0]!.stufe).toBe('ohne_inhalt');
|
||||
expect(abdeckung[1]!.stufe).toBe('abgedeckt');
|
||||
expect(abdeckung[1]!.stunden).toBe(80);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Abdeckung des Ausbildungsrahmenplans.
|
||||
*
|
||||
* Das ist die Ansicht, die im Zweifel gegenüber der IHK zählt: Welche
|
||||
* Berufsbildpositionen sind vermittelt, welche noch nicht?
|
||||
*
|
||||
* Die entscheidende Unterscheidung, die eine naive Zählung übersieht:
|
||||
* **Geplant** ist nicht **vermittelt.** Dass ein Handout zu Position B2
|
||||
* existiert, heißt nicht, dass der Azubi es durchgearbeitet hat. Und eine
|
||||
* Position, zu der es gar keinen Inhalt gibt, ist eine Lücke im Lehrgang
|
||||
* selbst – die schlimmste der vier Lagen, weil sie sonst unbemerkt bleibt.
|
||||
*/
|
||||
|
||||
export type Nachweisart = 'handout' | 'aufgabe' | 'checkliste';
|
||||
|
||||
export type Nachweis = {
|
||||
art: Nachweisart;
|
||||
id: string;
|
||||
titel: string;
|
||||
/** Hat der Azubi das nachweislich erledigt? */
|
||||
erledigt: boolean;
|
||||
/** Wann, für die Anzeige. */
|
||||
erledigtAm?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gewicht eines Nachweises.
|
||||
*
|
||||
* Eine abgenommene Praxisaufgabe und ein abgeschlossener Checklisten-Durchlauf
|
||||
* sind **belastbare** Nachweise: Da hat jemand etwas getan und ein anderer hat
|
||||
* es gesehen. Ein gelesenes Handout ist ein **stützender** Nachweis – es sagt
|
||||
* aus, dass der Stoff behandelt wurde, aber nicht, dass er angewendet wurde.
|
||||
*/
|
||||
export function istBelastbar(art: Nachweisart): boolean {
|
||||
return art === 'aufgabe' || art === 'checkliste';
|
||||
}
|
||||
|
||||
export type Abdeckungsstufe = 'ohne_inhalt' | 'offen' | 'angefangen' | 'abgedeckt';
|
||||
|
||||
export type Positionsabdeckung = {
|
||||
id: string;
|
||||
titel: string;
|
||||
kurz: string;
|
||||
gruppe: string;
|
||||
verordnung: string;
|
||||
stufe: Abdeckungsstufe;
|
||||
nachweise: Nachweis[];
|
||||
belastbare: number;
|
||||
stuetzende: number;
|
||||
/** Was es zu dieser Position überhaupt gibt – erledigt oder nicht. */
|
||||
geplant: number;
|
||||
};
|
||||
|
||||
export const stufenText: Record<Abdeckungsstufe, string> = {
|
||||
ohne_inhalt: 'kein Inhalt hinterlegt',
|
||||
offen: 'geplant, noch nicht bearbeitet',
|
||||
angefangen: 'angefangen',
|
||||
abgedeckt: 'vermittelt',
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Regel, nach der eine Position als vermittelt gilt.
|
||||
*
|
||||
* **Ein belastbarer Nachweis** (abgenommene Aufgabe, abgeschlossene Checkliste)
|
||||
* **oder zwei stützende** (gelesene Handouts). Ein einzelnes gelesenes Handout
|
||||
* genügt bewusst nicht – sonst wäre die Abdeckung nach dem ersten Klick voll
|
||||
* und die Ansicht wertlos.
|
||||
*
|
||||
* Die Regel steht hier an einer Stelle, damit sie sich ändern lässt, ohne die
|
||||
* Anzeige anzufassen – und damit im Zweifel nachlesbar ist, was gezählt wurde.
|
||||
*/
|
||||
export function stufeAus(nachweise: Nachweis[]): Abdeckungsstufe {
|
||||
if (nachweise.length === 0) return 'ohne_inhalt';
|
||||
|
||||
const erledigt = nachweise.filter((n) => n.erledigt);
|
||||
if (erledigt.length === 0) return 'offen';
|
||||
|
||||
const belastbare = erledigt.filter((n) => istBelastbar(n.art)).length;
|
||||
const stuetzende = erledigt.length - belastbare;
|
||||
|
||||
if (belastbare >= 1 || stuetzende >= 2) return 'abgedeckt';
|
||||
return 'angefangen';
|
||||
}
|
||||
|
||||
export type Position = {
|
||||
id: string;
|
||||
titel: string;
|
||||
kurz: string;
|
||||
gruppe: string;
|
||||
verordnung: string;
|
||||
};
|
||||
|
||||
export function berechneAbdeckung(
|
||||
positionen: Position[],
|
||||
nachweiseJePosition: Map<string, Nachweis[]>,
|
||||
): Positionsabdeckung[] {
|
||||
return positionen.map((position) => {
|
||||
const nachweise = nachweiseJePosition.get(position.id) ?? [];
|
||||
const erledigt = nachweise.filter((n) => n.erledigt);
|
||||
|
||||
return {
|
||||
...position,
|
||||
stufe: stufeAus(nachweise),
|
||||
nachweise,
|
||||
belastbare: erledigt.filter((n) => istBelastbar(n.art)).length,
|
||||
stuetzende: erledigt.filter((n) => !istBelastbar(n.art)).length,
|
||||
geplant: nachweise.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type Bilanz = {
|
||||
gesamt: number;
|
||||
abgedeckt: number;
|
||||
angefangen: number;
|
||||
offen: number;
|
||||
ohneInhalt: number;
|
||||
/** Anteil der vermittelten Positionen an allen. */
|
||||
prozent: number;
|
||||
};
|
||||
|
||||
export function bilanz(abdeckung: Positionsabdeckung[]): Bilanz {
|
||||
const zaehle = (stufe: Abdeckungsstufe) => abdeckung.filter((a) => a.stufe === stufe).length;
|
||||
|
||||
const gesamt = abdeckung.length;
|
||||
const abgedeckt = zaehle('abgedeckt');
|
||||
|
||||
return {
|
||||
gesamt,
|
||||
abgedeckt,
|
||||
angefangen: zaehle('angefangen'),
|
||||
offen: zaehle('offen'),
|
||||
ohneInhalt: zaehle('ohne_inhalt'),
|
||||
prozent: gesamt === 0 ? 0 : Math.round((abgedeckt / gesamt) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/** Nach Gruppe zusammengefasst – Abschnitt A, B und C stehen getrennt im Plan. */
|
||||
export function nachGruppen(
|
||||
abdeckung: Positionsabdeckung[],
|
||||
): { gruppe: string; positionen: Positionsabdeckung[]; bilanz: Bilanz }[] {
|
||||
const gruppen: string[] = [];
|
||||
for (const eintrag of abdeckung) {
|
||||
if (!gruppen.includes(eintrag.gruppe)) gruppen.push(eintrag.gruppe);
|
||||
}
|
||||
|
||||
return gruppen.map((gruppe) => {
|
||||
const eigene = abdeckung.filter((a) => a.gruppe === gruppe);
|
||||
return { gruppe, positionen: eigene, bilanz: bilanz(eigene) };
|
||||
});
|
||||
}
|
||||
|
||||
// --- Lernfelder der Berufsschule -------------------------------------------
|
||||
|
||||
export type Lernfeldabdeckung = {
|
||||
id: string;
|
||||
titel: string;
|
||||
jahr: number;
|
||||
stunden: number;
|
||||
stufe: Abdeckungsstufe;
|
||||
nachweise: Nachweis[];
|
||||
geplant: number;
|
||||
};
|
||||
|
||||
export function berechneLernfelder(
|
||||
lernfelder: { id: string; titel: string; jahr: number; stunden: number }[],
|
||||
nachweiseJeLernfeld: Map<string, Nachweis[]>,
|
||||
): Lernfeldabdeckung[] {
|
||||
return lernfelder.map((lernfeld) => {
|
||||
const nachweise = nachweiseJeLernfeld.get(lernfeld.id) ?? [];
|
||||
return { ...lernfeld, stufe: stufeAus(nachweise), nachweise, geplant: nachweise.length };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { inhalte } from '../inhalte';
|
||||
import { gelesenSet } from '../repos/fortschritt';
|
||||
import { staendeVon as aufgabenStaende } from '../repos/aufgaben';
|
||||
import { laeufeVon } from '../repos/checklisten';
|
||||
import { uebersicht as kartenUebersicht } from '../repos/karten';
|
||||
import { berichteVon, fehlendeWochen } from '../repos/berichtsheft';
|
||||
import { ergebnisVon, staendeVon as kontrollStaende } from '../repos/kontrollen';
|
||||
import { alsDatumText, alsZeitpunktText } from '../zeit';
|
||||
import { berechneAbdeckung, berechneLernfelder, type Nachweis } from './abdeckung';
|
||||
|
||||
/**
|
||||
* Sammelt aus Inhalten und Fortschritt alles, was die Auswertung braucht.
|
||||
*
|
||||
* Hier liegt die Übersetzung: Welcher erledigte Inhalt zählt auf welche
|
||||
* Berufsbildposition ein? Die Bewertung selbst steckt in ./abdeckung.
|
||||
*/
|
||||
|
||||
export function rahmenplanabdeckung(azubiId: string) {
|
||||
const alle = inhalte();
|
||||
const gelesen = gelesenSet(azubiId);
|
||||
const aufgaben = aufgabenStaende(azubiId);
|
||||
const laeufe = laeufeVon(azubiId);
|
||||
|
||||
const abgeschlosseneListen = new Map(
|
||||
laeufe.filter((l) => l.abgeschlossenAm).map((l) => [l.checklisteId, l.abgeschlossenAm!]),
|
||||
);
|
||||
|
||||
const jePosition = new Map<string, Nachweis[]>();
|
||||
const jeLernfeld = new Map<string, Nachweis[]>();
|
||||
|
||||
const anhaengen = (karte: Map<string, Nachweis[]>, schluessel: string, nachweis: Nachweis) => {
|
||||
const bestand = karte.get(schluessel) ?? [];
|
||||
bestand.push(nachweis);
|
||||
karte.set(schluessel, bestand);
|
||||
};
|
||||
|
||||
// --- Handouts ---
|
||||
for (const handout of alle.handouts) {
|
||||
const nachweis: Nachweis = {
|
||||
art: 'handout',
|
||||
id: handout.id,
|
||||
titel: handout.titel,
|
||||
erledigt: gelesen.has(handout.id),
|
||||
};
|
||||
for (const position of handout.rahmenplan) anhaengen(jePosition, position, nachweis);
|
||||
anhaengen(jeLernfeld, handout.lernfeld, nachweis);
|
||||
}
|
||||
|
||||
// --- Praxisaufgaben ---
|
||||
for (const aufgabe of alle.aufgaben) {
|
||||
const stand = aufgaben.get(aufgabe.id);
|
||||
const nachweis: Nachweis = {
|
||||
art: 'aufgabe',
|
||||
id: aufgabe.id,
|
||||
titel: aufgabe.titel,
|
||||
erledigt: stand?.status === 'abgenommen',
|
||||
erledigtAm: stand?.abgenommenAm ? alsZeitpunktText(stand.abgenommenAm) : null,
|
||||
};
|
||||
for (const position of aufgabe.rahmenplan) anhaengen(jePosition, position, nachweis);
|
||||
anhaengen(jeLernfeld, aufgabe.lernfeld, nachweis);
|
||||
}
|
||||
|
||||
// --- Checklisten ---
|
||||
for (const liste of alle.checklisten) {
|
||||
const abgeschlossenAm = abgeschlosseneListen.get(liste.id);
|
||||
const nachweis: Nachweis = {
|
||||
art: 'checkliste',
|
||||
id: liste.id,
|
||||
titel: liste.titel,
|
||||
erledigt: Boolean(abgeschlossenAm),
|
||||
erledigtAm: abgeschlossenAm ? alsZeitpunktText(abgeschlossenAm) : null,
|
||||
};
|
||||
for (const position of liste.rahmenplan) anhaengen(jePosition, position, nachweis);
|
||||
if (liste.lernfeld) anhaengen(jeLernfeld, liste.lernfeld, nachweis);
|
||||
}
|
||||
|
||||
return {
|
||||
positionen: berechneAbdeckung(alle.positionen, jePosition),
|
||||
lernfelder: berechneLernfelder(alle.lernfelder, jeLernfeld),
|
||||
gruppennamen: alle.rahmenplan.gruppen,
|
||||
};
|
||||
}
|
||||
|
||||
/** Der persönliche Stand des Azubis – was er selbst sehen will. */
|
||||
export function eigenerStand(azubiId: string, beginn: string | null) {
|
||||
const alle = inhalte();
|
||||
const gelesen = gelesenSet(azubiId);
|
||||
const aufgaben = aufgabenStaende(azubiId);
|
||||
const karten = kartenUebersicht(azubiId, alle.karten.map((k) => k.id));
|
||||
const berichte = berichteVon(azubiId);
|
||||
const kontrollen = kontrollStaende(azubiId);
|
||||
|
||||
const ergebnisse = alle.kontrollen
|
||||
.map((inhalt) => {
|
||||
const stand = kontrollen.get(inhalt.id);
|
||||
if (!stand?.korrigiertAm) return null;
|
||||
const ergebnis = ergebnisVon(stand.id, inhalt);
|
||||
return {
|
||||
id: inhalt.id,
|
||||
titel: inhalt.titel,
|
||||
woche: inhalt.woche,
|
||||
prozent: ergebnis.prozent,
|
||||
note: ergebnis.note,
|
||||
notentext: ergebnis.notentext,
|
||||
erreicht: ergebnis.erreicht,
|
||||
moeglich: ergebnis.moeglich,
|
||||
korrigiertAm: alsDatumText(stand.korrigiertAm.slice(0, 10)),
|
||||
};
|
||||
})
|
||||
.filter((e) => e !== null)
|
||||
.sort((a, b) => a.woche - b.woche);
|
||||
|
||||
return {
|
||||
handouts: { erledigt: gelesen.size, gesamt: alle.handouts.length },
|
||||
karten: { erledigt: karten.gelernt, gesamt: karten.gesamt, faellig: karten.faellig },
|
||||
aufgaben: {
|
||||
erledigt: alle.aufgaben.filter((a) => aufgaben.get(a.id)?.status === 'abgenommen').length,
|
||||
gesamt: alle.aufgaben.length,
|
||||
offen: alle.aufgaben.filter((a) => {
|
||||
const status = aufgaben.get(a.id)?.status ?? 'offen';
|
||||
return status !== 'abgenommen';
|
||||
}).length,
|
||||
zurAbnahme: alle.aufgaben.filter((a) => aufgaben.get(a.id)?.status === 'zur_abnahme').length,
|
||||
},
|
||||
berichtsheft: {
|
||||
gesamt: berichte.length,
|
||||
abgezeichnet: berichte.filter((b) => b.status === 'abgezeichnet').length,
|
||||
eingereicht: berichte.filter((b) => b.status === 'eingereicht').length,
|
||||
offen: berichte.filter((b) => b.status === 'entwurf' || b.status === 'zurueckgegeben').length,
|
||||
fehlendeWochen: beginn ? fehlendeWochen(azubiId, beginn).length : 0,
|
||||
},
|
||||
kontrollen: {
|
||||
gesamt: alle.kontrollen.length,
|
||||
korrigiert: ergebnisse.length,
|
||||
ergebnisse,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type EigenerStand = ReturnType<typeof eigenerStand>;
|
||||
export type Rahmenplanabdeckung = ReturnType<typeof rahmenplanabdeckung>;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Baustein 9: Feedbackgespräch.
|
||||
--
|
||||
-- Ein Gespräch wird von beiden Seiten bestätigt und ist danach unveränderlich –
|
||||
-- es gehört in die Ausbildungsakte. Deshalb wird der Inhalt als Ganzes
|
||||
-- gespeichert, einschließlich der verwendeten Vorlage: Ändert der Ausbilder
|
||||
-- später die Kriterien in content/feedbackgespraech.yaml, bleibt ein bereits
|
||||
-- geführtes Gespräch genau so, wie es geführt wurde.
|
||||
|
||||
CREATE TABLE feedbackgespraech (
|
||||
id TEXT PRIMARY KEY,
|
||||
ausbildung_id TEXT NOT NULL REFERENCES ausbildung (id) ON DELETE CASCADE,
|
||||
datum TEXT NOT NULL,
|
||||
-- Vollständiger Inhalt als JSON: Kriterien samt Bewertungen beider Seiten,
|
||||
-- Stärken, Entwicklungsfelder, Ziele, Notizen zum Leitfaden.
|
||||
inhalt TEXT NOT NULL DEFAULT '{}',
|
||||
bestaetigt_azubi INTEGER NOT NULL DEFAULT 0 CHECK (bestaetigt_azubi IN (0, 1)),
|
||||
bestaetigt_azubi_am TEXT,
|
||||
bestaetigt_ausbilder INTEGER NOT NULL DEFAULT 0 CHECK (bestaetigt_ausbilder IN (0, 1)),
|
||||
bestaetigt_ausbilder_am TEXT,
|
||||
angelegt_von TEXT NOT NULL REFERENCES benutzer (id),
|
||||
angelegt_am TEXT NOT NULL,
|
||||
geaendert_am TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX feedbackgespraech_ausbildung_idx ON feedbackgespraech (ausbildung_id, datum);
|
||||
@@ -301,6 +301,28 @@ export const kontrollAntwort = sqliteTable(
|
||||
(t) => [uniqueIndex('kontroll_antwort_idx').on(t.kontrolleId, t.frageId)],
|
||||
);
|
||||
|
||||
export const feedbackgespraech = sqliteTable(
|
||||
'feedbackgespraech',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
ausbildungId: text('ausbildung_id')
|
||||
.notNull()
|
||||
.references(() => ausbildung.id, { onDelete: 'cascade' }),
|
||||
datum: text('datum').notNull(),
|
||||
inhalt: text('inhalt').notNull().default('{}'),
|
||||
bestaetigtAzubi: integer('bestaetigt_azubi').notNull().default(0),
|
||||
bestaetigtAzubiAm: text('bestaetigt_azubi_am'),
|
||||
bestaetigtAusbilder: integer('bestaetigt_ausbilder').notNull().default(0),
|
||||
bestaetigtAusbilderAm: text('bestaetigt_ausbilder_am'),
|
||||
angelegtVon: text('angelegt_von')
|
||||
.notNull()
|
||||
.references(() => benutzer.id),
|
||||
angelegtAm: text('angelegt_am').notNull(),
|
||||
geaendertAm: text('geaendert_am').notNull(),
|
||||
},
|
||||
(t) => [index('feedbackgespraech_ausbildung_idx').on(t.ausbildungId, t.datum)],
|
||||
);
|
||||
|
||||
export type Benutzer = typeof benutzer.$inferSelect;
|
||||
export type Sitzung = typeof sitzung.$inferSelect;
|
||||
export type Ausbildung = typeof ausbildung.$inferSelect;
|
||||
@@ -316,6 +338,7 @@ export type Tagesnotiz = typeof tagesnotiz.$inferSelect;
|
||||
export type Wochenbericht = typeof wochenbericht.$inferSelect;
|
||||
export type Kontrolle = typeof kontrolle.$inferSelect;
|
||||
export type KontrollAntwort = typeof kontrollAntwort.$inferSelect;
|
||||
export type Feedbackgespraech = typeof feedbackgespraech.$inferSelect;
|
||||
|
||||
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
|
||||
export const alleTabellen = {
|
||||
@@ -334,4 +357,5 @@ export const alleTabellen = {
|
||||
wochenbericht,
|
||||
kontrolle,
|
||||
kontrollAntwort,
|
||||
feedbackgespraech,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { istAbgeschlossen, leererInhalt, leseInhalt, liegtWeitAuseinander, zusammenfassung } from './inhalt';
|
||||
import type { Feedbackvorlage } from '../inhalte/schema';
|
||||
|
||||
const vorlage: Feedbackvorlage = {
|
||||
leitfaden: [{ abschnitt: 'Rückblick', fragen: ['Was lief gut?'] }],
|
||||
kriterien: [
|
||||
{ id: 'selbst', titel: 'Selbstständigkeit', erlaeuterung: '…' },
|
||||
{ id: 'sorgfalt', titel: 'Sorgfalt', erlaeuterung: '…' },
|
||||
],
|
||||
skala: [
|
||||
{ wert: 1, text: 'sehr gut' },
|
||||
{ wert: 2, text: 'gut' },
|
||||
{ wert: 3, text: 'in Ordnung' },
|
||||
{ wert: 4, text: 'ausbaufähig' },
|
||||
{ wert: 5, text: 'Nachholbedarf' },
|
||||
],
|
||||
hinweis: '',
|
||||
};
|
||||
|
||||
describe('Leerer Inhalt', () => {
|
||||
it('legt für jedes Kriterium eine Bewertung an', () => {
|
||||
const inhalt = leererInhalt(vorlage);
|
||||
expect(inhalt.bewertungen).toHaveLength(2);
|
||||
expect(inhalt.bewertungen[0]!.ausbilder).toBeNull();
|
||||
expect(inhalt.bewertungen[0]!.azubi).toBeNull();
|
||||
});
|
||||
|
||||
it('kopiert die Vorlage mit – sie muss beim Gespräch bleiben', () => {
|
||||
const inhalt = leererInhalt(vorlage);
|
||||
expect(inhalt.kriterien).toEqual(vorlage.kriterien);
|
||||
expect(inhalt.skala).toEqual(vorlage.skala);
|
||||
});
|
||||
|
||||
it('legt für jeden Leitfadenabschnitt ein Notizfeld an', () => {
|
||||
expect(leererInhalt(vorlage).leitfadennotizen).toEqual([{ abschnitt: 'Rückblick', notiz: '' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gespeicherten Inhalt lesen', () => {
|
||||
it('liest einen vollständigen Datensatz', () => {
|
||||
const gespeichert = JSON.stringify({
|
||||
...leererInhalt(vorlage),
|
||||
staerken: 'Arbeitet sehr sauber.',
|
||||
bewertungen: [
|
||||
{ kriteriumId: 'selbst', ausbilder: 2, azubi: 3, bemerkung: 'wird besser' },
|
||||
{ kriteriumId: 'sorgfalt', ausbilder: 1, azubi: 1, bemerkung: '' },
|
||||
],
|
||||
});
|
||||
|
||||
const inhalt = leseInhalt(gespeichert, vorlage);
|
||||
expect(inhalt.staerken).toBe('Arbeitet sehr sauber.');
|
||||
expect(inhalt.bewertungen[0]!.ausbilder).toBe(2);
|
||||
expect(inhalt.bewertungen[0]!.azubi).toBe(3);
|
||||
});
|
||||
|
||||
it('fällt bei kaputtem JSON auf einen leeren Inhalt zurück', () => {
|
||||
const inhalt = leseInhalt('das ist kein JSON', vorlage);
|
||||
expect(inhalt.kriterien).toEqual(vorlage.kriterien);
|
||||
expect(inhalt.staerken).toBe('');
|
||||
});
|
||||
|
||||
it('wirft Bewertungen außerhalb der Skala weg', () => {
|
||||
const gespeichert = JSON.stringify({
|
||||
...leererInhalt(vorlage),
|
||||
bewertungen: [{ kriteriumId: 'selbst', ausbilder: 99, azubi: -3, bemerkung: '' }],
|
||||
});
|
||||
const inhalt = leseInhalt(gespeichert, vorlage);
|
||||
expect(inhalt.bewertungen[0]!.ausbilder).toBeNull();
|
||||
expect(inhalt.bewertungen[0]!.azubi).toBeNull();
|
||||
});
|
||||
|
||||
it('ergänzt fehlende Kriterien, statt die Anzeige auseinanderfallen zu lassen', () => {
|
||||
const gespeichert = JSON.stringify({
|
||||
...leererInhalt(vorlage),
|
||||
bewertungen: [{ kriteriumId: 'selbst', ausbilder: 2, azubi: 2, bemerkung: '' }],
|
||||
});
|
||||
const inhalt = leseInhalt(gespeichert, vorlage);
|
||||
expect(inhalt.bewertungen).toHaveLength(2);
|
||||
expect(inhalt.bewertungen.find((b) => b.kriteriumId === 'sorgfalt')).toBeDefined();
|
||||
});
|
||||
|
||||
it('behält die alte Vorlage eines geführten Gesprächs bei', () => {
|
||||
// Der Ausbilder hat die Kriterien inzwischen umbenannt – das Gespräch bleibt.
|
||||
const alt = JSON.stringify({
|
||||
...leererInhalt(vorlage),
|
||||
kriterien: [{ id: 'alt', titel: 'Altes Kriterium', erlaeuterung: 'von damals' }],
|
||||
});
|
||||
const neueVorlage: Feedbackvorlage = {
|
||||
...vorlage,
|
||||
kriterien: [{ id: 'neu', titel: 'Neues Kriterium', erlaeuterung: 'von heute' }],
|
||||
};
|
||||
|
||||
const inhalt = leseInhalt(alt, neueVorlage);
|
||||
expect(inhalt.kriterien[0]!.titel).toBe('Altes Kriterium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Abschluss', () => {
|
||||
it('gilt erst, wenn beide bestätigt haben', () => {
|
||||
expect(istAbgeschlossen({ bestaetigtAzubi: false, bestaetigtAusbilder: false })).toBe(false);
|
||||
expect(istAbgeschlossen({ bestaetigtAzubi: true, bestaetigtAusbilder: false })).toBe(false);
|
||||
expect(istAbgeschlossen({ bestaetigtAzubi: false, bestaetigtAusbilder: true })).toBe(false);
|
||||
expect(istAbgeschlossen({ bestaetigtAzubi: true, bestaetigtAusbilder: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Weit auseinander', () => {
|
||||
// Die Grenze ist die eigentliche Aussage des Bogens: Eine Stufe Unterschied
|
||||
// ist Alltag, zwei Stufen sind ein Gesprächsanlass.
|
||||
it('zieht die Grenze bei zwei Stufen', () => {
|
||||
expect(liegtWeitAuseinander({ ausbilder: 2, azubi: 3 })).toBe(false);
|
||||
expect(liegtWeitAuseinander({ ausbilder: 2, azubi: 4 })).toBe(true);
|
||||
expect(liegtWeitAuseinander({ ausbilder: 5, azubi: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('gilt in beide Richtungen gleich', () => {
|
||||
expect(liegtWeitAuseinander({ ausbilder: 1, azubi: 4 })).toBe(true);
|
||||
expect(liegtWeitAuseinander({ ausbilder: 4, azubi: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('schweigt, solange eine Seite nichts eingetragen hat', () => {
|
||||
expect(liegtWeitAuseinander({ ausbilder: 1, azubi: null })).toBe(false);
|
||||
expect(liegtWeitAuseinander({ ausbilder: null, azubi: 5 })).toBe(false);
|
||||
expect(liegtWeitAuseinander({ ausbilder: null, azubi: null })).toBe(false);
|
||||
expect(liegtWeitAuseinander(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zusammenfassung', () => {
|
||||
it('rechnet die beiden Schnitte getrennt', () => {
|
||||
const inhalt = {
|
||||
...leererInhalt(vorlage),
|
||||
bewertungen: [
|
||||
{ kriteriumId: 'selbst', ausbilder: 2, azubi: 3, bemerkung: '' },
|
||||
{ kriteriumId: 'sorgfalt', ausbilder: 1, azubi: 2, bemerkung: '' },
|
||||
],
|
||||
};
|
||||
const summe = zusammenfassung(inhalt);
|
||||
|
||||
expect(summe.schnittAusbilder).toBe(1.5);
|
||||
expect(summe.schnittAzubi).toBe(2.5);
|
||||
expect(summe.bewertet).toBe(2);
|
||||
expect(summe.gesamt).toBe(2);
|
||||
});
|
||||
|
||||
it('nennt Kriterien, bei denen beide Seiten deutlich auseinanderliegen', () => {
|
||||
const inhalt = {
|
||||
...leererInhalt(vorlage),
|
||||
bewertungen: [
|
||||
{ kriteriumId: 'selbst', ausbilder: 1, azubi: 4, bemerkung: '' },
|
||||
{ kriteriumId: 'sorgfalt', ausbilder: 2, azubi: 3, bemerkung: '' },
|
||||
],
|
||||
};
|
||||
expect(zusammenfassung(inhalt).abweichungen).toEqual(['Selbstständigkeit']);
|
||||
});
|
||||
|
||||
it('meldet keine Abweichung, solange eine Seite nichts eingetragen hat', () => {
|
||||
const inhalt = {
|
||||
...leererInhalt(vorlage),
|
||||
bewertungen: [{ kriteriumId: 'selbst', ausbilder: 1, azubi: null, bemerkung: '' }],
|
||||
};
|
||||
expect(zusammenfassung(inhalt).abweichungen).toEqual([]);
|
||||
});
|
||||
|
||||
it('kommt ohne Bewertungen zurecht', () => {
|
||||
const summe = zusammenfassung(leererInhalt(vorlage));
|
||||
expect(summe.schnittAusbilder).toBeNull();
|
||||
expect(summe.bewertet).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { FeedbackKriterium, Feedbackvorlage } from '../inhalte/schema';
|
||||
|
||||
/**
|
||||
* Der Inhalt eines Feedbackgesprächs.
|
||||
*
|
||||
* Wird als Ganzes gespeichert – **einschließlich der verwendeten Vorlage.**
|
||||
* Ein bestätigtes Gespräch gehört in die Ausbildungsakte und muss in zwei
|
||||
* Jahren noch dasselbe aussagen, auch wenn der Ausbilder die Kriterien
|
||||
* inzwischen umformuliert hat.
|
||||
*/
|
||||
|
||||
export type Bewertung = {
|
||||
kriteriumId: string;
|
||||
/** Einschätzung des Ausbilders, 1 bis 5. */
|
||||
ausbilder: number | null;
|
||||
/** Selbsteinschätzung des Azubis, 1 bis 5. */
|
||||
azubi: number | null;
|
||||
bemerkung: string;
|
||||
};
|
||||
|
||||
export type Gespraechsinhalt = {
|
||||
/** Kopie der Vorlage zum Zeitpunkt des Gesprächs. */
|
||||
kriterien: FeedbackKriterium[];
|
||||
skala: { wert: number; text: string }[];
|
||||
bewertungen: Bewertung[];
|
||||
/** Freie Notizen zu den Abschnitten des Leitfadens. */
|
||||
leitfadennotizen: { abschnitt: string; notiz: string }[];
|
||||
staerken: string;
|
||||
entwicklungsfelder: string;
|
||||
ziele: string;
|
||||
sonstiges: string;
|
||||
};
|
||||
|
||||
export function leererInhalt(vorlage: Feedbackvorlage): Gespraechsinhalt {
|
||||
return {
|
||||
kriterien: vorlage.kriterien,
|
||||
skala: vorlage.skala,
|
||||
bewertungen: vorlage.kriterien.map((k) => ({
|
||||
kriteriumId: k.id,
|
||||
ausbilder: null,
|
||||
azubi: null,
|
||||
bemerkung: '',
|
||||
})),
|
||||
leitfadennotizen: vorlage.leitfaden.map((a) => ({ abschnitt: a.abschnitt, notiz: '' })),
|
||||
staerken: '',
|
||||
entwicklungsfelder: '',
|
||||
ziele: '',
|
||||
sonstiges: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Liest den gespeicherten Inhalt und härtet ihn gegen Unsinn ab. */
|
||||
export function leseInhalt(roh: string, ersatz: Feedbackvorlage): Gespraechsinhalt {
|
||||
let gelesen: unknown;
|
||||
try {
|
||||
gelesen = JSON.parse(roh);
|
||||
} catch {
|
||||
return leererInhalt(ersatz);
|
||||
}
|
||||
|
||||
if (typeof gelesen !== 'object' || gelesen === null) return leererInhalt(ersatz);
|
||||
const wert = gelesen as Record<string, unknown>;
|
||||
|
||||
const kriterien = Array.isArray(wert.kriterien)
|
||||
? (wert.kriterien as FeedbackKriterium[]).filter(
|
||||
(k) => typeof k?.id === 'string' && typeof k?.titel === 'string',
|
||||
)
|
||||
: ersatz.kriterien;
|
||||
|
||||
const skala = Array.isArray(wert.skala) && wert.skala.length > 0
|
||||
? (wert.skala as { wert: number; text: string }[])
|
||||
: ersatz.skala;
|
||||
|
||||
const bewertungen = Array.isArray(wert.bewertungen)
|
||||
? (wert.bewertungen as Record<string, unknown>[]).map((b) => ({
|
||||
kriteriumId: String(b.kriteriumId ?? ''),
|
||||
ausbilder: stufe(b.ausbilder, skala),
|
||||
azubi: stufe(b.azubi, skala),
|
||||
bemerkung: typeof b.bemerkung === 'string' ? b.bemerkung : '',
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Fehlende Kriterien ergänzen, damit die Anzeige nicht auseinanderfällt.
|
||||
for (const kriterium of kriterien) {
|
||||
if (!bewertungen.some((b) => b.kriteriumId === kriterium.id)) {
|
||||
bewertungen.push({ kriteriumId: kriterium.id, ausbilder: null, azubi: null, bemerkung: '' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kriterien,
|
||||
skala,
|
||||
bewertungen,
|
||||
leitfadennotizen: Array.isArray(wert.leitfadennotizen)
|
||||
? (wert.leitfadennotizen as Record<string, unknown>[]).map((n) => ({
|
||||
abschnitt: String(n.abschnitt ?? ''),
|
||||
notiz: typeof n.notiz === 'string' ? n.notiz : '',
|
||||
}))
|
||||
: ersatz.leitfaden.map((a) => ({ abschnitt: a.abschnitt, notiz: '' })),
|
||||
staerken: text(wert.staerken),
|
||||
entwicklungsfelder: text(wert.entwicklungsfelder),
|
||||
ziele: text(wert.ziele),
|
||||
sonstiges: text(wert.sonstiges),
|
||||
};
|
||||
}
|
||||
|
||||
function stufe(wert: unknown, skala: { wert: number }[]): number | null {
|
||||
const zahl = Number(wert);
|
||||
if (!Number.isFinite(zahl)) return null;
|
||||
return skala.some((s) => s.wert === zahl) ? zahl : null;
|
||||
}
|
||||
|
||||
function text(wert: unknown): string {
|
||||
return typeof wert === 'string' ? wert : '';
|
||||
}
|
||||
|
||||
export type Gespraechsstand = {
|
||||
bestaetigtAzubi: boolean;
|
||||
bestaetigtAusbilder: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ein Gespräch ist abgeschlossen, wenn **beide** bestätigt haben. Danach ist
|
||||
* es unveränderlich – das ist der Sinn der Bestätigung.
|
||||
*/
|
||||
export function istAbgeschlossen(stand: Gespraechsstand): boolean {
|
||||
return stand.bestaetigtAzubi && stand.bestaetigtAusbilder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liegen beide Seiten weit auseinander?
|
||||
*
|
||||
* „Weit" heißt: zwei Stufen oder mehr. Eine Stufe Unterschied ist normal und
|
||||
* sagt nichts; ab zwei Stufen reden zwei Menschen über verschiedene Dinge –
|
||||
* genau darüber lohnt das Gespräch.
|
||||
*
|
||||
* Die Regel steht hier und nur hier: Ansicht, Übersicht und PDF fragen sie ab.
|
||||
*/
|
||||
export function liegtWeitAuseinander(bewertung: Pick<Bewertung, 'ausbilder' | 'azubi'> | undefined): boolean {
|
||||
if (!bewertung || bewertung.ausbilder === null || bewertung.azubi === null) return false;
|
||||
return Math.abs(bewertung.ausbilder - bewertung.azubi) >= 2;
|
||||
}
|
||||
|
||||
/** Zusammenfassung für die Übersicht. */
|
||||
export function zusammenfassung(inhalt: Gespraechsinhalt): {
|
||||
bewertet: number;
|
||||
gesamt: number;
|
||||
schnittAusbilder: number | null;
|
||||
schnittAzubi: number | null;
|
||||
/** Kriterien, bei denen beide Seiten mehr als eine Stufe auseinanderliegen. */
|
||||
abweichungen: string[];
|
||||
} {
|
||||
const mitAusbilder = inhalt.bewertungen.filter((b) => b.ausbilder !== null);
|
||||
const mitAzubi = inhalt.bewertungen.filter((b) => b.azubi !== null);
|
||||
|
||||
const abweichungen = inhalt.bewertungen
|
||||
.filter(liegtWeitAuseinander)
|
||||
.map((b) => inhalt.kriterien.find((k) => k.id === b.kriteriumId)?.titel ?? b.kriteriumId);
|
||||
|
||||
return {
|
||||
bewertet: mitAusbilder.length,
|
||||
gesamt: inhalt.kriterien.length,
|
||||
schnittAusbilder: schnitt(mitAusbilder.map((b) => b.ausbilder!)),
|
||||
schnittAzubi: schnitt(mitAzubi.map((b) => b.azubi!)),
|
||||
abweichungen,
|
||||
};
|
||||
}
|
||||
|
||||
function schnitt(werte: number[]): number | null {
|
||||
if (werte.length === 0) return null;
|
||||
return Math.round((werte.reduce((a, b) => a + b, 0) / werte.length) * 10) / 10;
|
||||
}
|
||||
@@ -82,6 +82,17 @@ karten:
|
||||
**Antwort:** So.
|
||||
`;
|
||||
|
||||
const FEEDBACK = `leitfaden:
|
||||
- abschnitt: Rückblick
|
||||
fragen: [Was lief gut?]
|
||||
kriterien:
|
||||
- { id: selbst, titel: Selbstständigkeit, erlaeuterung: Arbeitet eigenständig. }
|
||||
skala:
|
||||
- { wert: 1, text: sehr gut }
|
||||
- { wert: 5, text: Nachholbedarf }
|
||||
hinweis: Von beiden zu bestätigen.
|
||||
`;
|
||||
|
||||
const GLOSSAR = `- begriff: Subnetzmaske
|
||||
formen: [Maske]
|
||||
erklaerung: Trennt Netz und Rechner.
|
||||
@@ -95,6 +106,7 @@ function baueBeispiel(aenderungen: Record<string, string | null> = {}): string {
|
||||
const dateien: Record<string, string> = {
|
||||
'rahmenplan.yaml': RAHMENPLAN,
|
||||
'glossar.yaml': GLOSSAR,
|
||||
'feedbackgespraech.yaml': FEEDBACK,
|
||||
'wochen/01-netzwerk.yaml': WOCHE,
|
||||
'handouts/N1-subnetting.md': HANDOUT,
|
||||
};
|
||||
@@ -220,6 +232,12 @@ describe('Fehlerdarstellung', () => {
|
||||
expect(fehler!.message).toContain('npm run inhalte:pruefen');
|
||||
});
|
||||
|
||||
it('meldet eine Feedbackvorlage ohne Kriterien', () => {
|
||||
const ohne = FEEDBACK.replace(/kriterien:\n( - .*\n)+/, 'kriterien: []\n');
|
||||
const fehler = fehlerVon(baueBeispiel({ 'feedbackgespraech.yaml': ohne }));
|
||||
expect(fehler!.fehler.some((f) => f.meldung.includes('Bewertungskriterium'))).toBe(true);
|
||||
});
|
||||
|
||||
it('meldet kaputtes YAML mit der Zeile, statt einen Stapelabzug zu werfen', () => {
|
||||
const kaputt = RAHMENPLAN.replace('lernfelder:', 'lernfelder:\n - [ das ist kein gültiges YAML');
|
||||
const fehler = fehlerVon(baueBeispiel({ 'rahmenplan.yaml': kaputt }));
|
||||
|
||||
@@ -6,6 +6,7 @@ import { abschnitt, inAbschnitte, nurText, type Abschnitt } from './markdown';
|
||||
import {
|
||||
aufgabeKopfSchema,
|
||||
checklisteSchema,
|
||||
feedbackSchema,
|
||||
glossarSchema,
|
||||
handoutKopfSchema,
|
||||
kontrolleSchema,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
type AufgabeKopf,
|
||||
type Berufsbildposition,
|
||||
type Checkliste,
|
||||
type Feedbackvorlage,
|
||||
type GlossarEintrag,
|
||||
type HandoutKopf,
|
||||
type Kontrolle,
|
||||
@@ -88,6 +90,8 @@ export type Inhalte = {
|
||||
glossar: GlossarEintrag[];
|
||||
glossarNach: Map<string, GlossarEintrag>;
|
||||
|
||||
feedback: Feedbackvorlage;
|
||||
|
||||
/** Alle Dateien, die eingelesen wurden – für das Neuladen im Entwicklungsbetrieb. */
|
||||
dateien: string[];
|
||||
gelesenAm: string;
|
||||
@@ -332,6 +336,21 @@ export function ladeInhalte(wurzel: string): Inhalte {
|
||||
}
|
||||
}
|
||||
|
||||
// --- feedbackgespraech.yaml ---------------------------------------------
|
||||
const feedbackDatei = path.join(wurzel, 'feedbackgespraech.yaml');
|
||||
dateien.push(feedbackDatei);
|
||||
const feedbackGelesen = leseYaml(feedbackDatei, zeigeName(feedbackDatei), feedbackSchema, sammler);
|
||||
|
||||
if (feedbackGelesen) {
|
||||
pruefeDoppelte(
|
||||
feedbackGelesen.werte.kriterien.map((k) => k.id),
|
||||
feedbackGelesen,
|
||||
'kriterien',
|
||||
'Kriterienkennung',
|
||||
sammler,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Verweise prüfen ----------------------------------------------------
|
||||
pruefeVerweise({
|
||||
sammler,
|
||||
@@ -398,6 +417,7 @@ export function ladeInhalte(wurzel: string): Inhalte {
|
||||
kontrolleNach,
|
||||
glossar,
|
||||
glossarNach,
|
||||
feedback: feedbackGelesen!.werte,
|
||||
dateien,
|
||||
gelesenAm: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -169,6 +169,28 @@ export const glossarEintragSchema = z.object({
|
||||
|
||||
export const glossarSchema = z.array(glossarEintragSchema).min(1, 'Das Glossar ist leer.');
|
||||
|
||||
// --- feedbackgespraech.yaml ----------------------------------------------
|
||||
|
||||
export const leitfadenAbschnittSchema = z.object({
|
||||
abschnitt: nichtLeer('Der Name des Abschnitts'),
|
||||
fragen: z.array(z.string().min(1)).min(1, 'Ein Abschnitt ohne Fragen ergibt keinen Sinn.'),
|
||||
});
|
||||
|
||||
export const feedbackKriteriumSchema = z.object({
|
||||
id: nichtLeer('Die Kennung des Kriteriums'),
|
||||
titel: nichtLeer('Der Titel'),
|
||||
erlaeuterung: nichtLeer('Die Erläuterung'),
|
||||
});
|
||||
|
||||
export const feedbackSchema = z.object({
|
||||
leitfaden: z.array(leitfadenAbschnittSchema).min(1, 'Der Gesprächsleitfaden ist leer.'),
|
||||
kriterien: z.array(feedbackKriteriumSchema).min(1, 'Es muss mindestens ein Bewertungskriterium geben.'),
|
||||
skala: z
|
||||
.array(z.object({ wert: z.number().int().min(1).max(10), text: nichtLeer('Der Text der Stufe') }))
|
||||
.min(2, 'Eine Skala braucht mindestens zwei Stufen.'),
|
||||
hinweis: z.string().default(''),
|
||||
});
|
||||
|
||||
// --- Typen ----------------------------------------------------------------
|
||||
|
||||
export type Berufsbildposition = z.infer<typeof berufsbildpositionSchema>;
|
||||
@@ -184,3 +206,5 @@ export type ChecklistenGruppe = z.infer<typeof checklistenGruppeSchema>;
|
||||
export type Kontrolle = z.infer<typeof kontrolleSchema>;
|
||||
export type Kontrollfrage = z.infer<typeof kontrollfrageSchema>;
|
||||
export type GlossarEintrag = z.infer<typeof glossarEintragSchema>;
|
||||
export type Feedbackvorlage = z.infer<typeof feedbackSchema>;
|
||||
export type FeedbackKriterium = z.infer<typeof feedbackKriteriumSchema>;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
abschnitt,
|
||||
alsPuffer,
|
||||
angabe,
|
||||
FARBEN,
|
||||
fuerPdf,
|
||||
fusszeilen,
|
||||
INHALTSBREITE,
|
||||
kopf,
|
||||
neuesDokument,
|
||||
platzPruefen,
|
||||
SEITE,
|
||||
unterschriften,
|
||||
type Dokument,
|
||||
} from './grundlage';
|
||||
import { liegtWeitAuseinander, type Gespraechsinhalt } from '../feedback/inhalt';
|
||||
|
||||
/**
|
||||
* Das Feedbackgespräch als PDF für die Ausbildungsakte.
|
||||
*
|
||||
* Enthält beide Einschätzungen nebeneinander – die des Ausbilders und die
|
||||
* Selbsteinschätzung des Azubis. Genau das ist der Wert des Blattes: Wo beide
|
||||
* auseinanderliegen, lohnt das Gespräch beim nächsten Mal.
|
||||
*/
|
||||
|
||||
export type Gespraechsangaben = {
|
||||
datum: string;
|
||||
azubi: string;
|
||||
ausbilder: string;
|
||||
beruf: string;
|
||||
inhalt: Gespraechsinhalt;
|
||||
bestaetigtAzubiAm: string | null;
|
||||
bestaetigtAusbilderAm: string | null;
|
||||
};
|
||||
|
||||
export async function feedbackPdf(angaben: Gespraechsangaben): Promise<Buffer> {
|
||||
const dokument = neuesDokument(`Feedbackgespräch ${angaben.datum}`, angaben.ausbilder);
|
||||
const { inhalt } = angaben;
|
||||
|
||||
kopf(dokument, {
|
||||
titel: 'Feedbackgespräch',
|
||||
untertitel: fuerPdf(`${angaben.azubi} · ${angaben.beruf}`),
|
||||
rechts: fuerPdf(angaben.datum),
|
||||
});
|
||||
|
||||
angabe(dokument, 'Datum', fuerPdf(angaben.datum));
|
||||
angabe(dokument, 'Auszubildender', fuerPdf(angaben.azubi));
|
||||
angabe(dokument, 'Ausbilder', fuerPdf(angaben.ausbilder));
|
||||
|
||||
// --- Bewertungsbogen ---
|
||||
abschnitt(dokument, 'Bewertungsbogen', fuerPdf(skalentext(inhalt)));
|
||||
bewertungstabelle(dokument, inhalt);
|
||||
|
||||
// --- Freitexte ---
|
||||
freitext(dokument, 'Stärken', inhalt.staerken);
|
||||
freitext(dokument, 'Entwicklungsfelder', inhalt.entwicklungsfelder);
|
||||
freitext(dokument, 'Vereinbarte Ziele bis zum nächsten Gespräch', inhalt.ziele);
|
||||
|
||||
const notizen = inhalt.leitfadennotizen.filter((n) => n.notiz.trim() !== '');
|
||||
if (notizen.length > 0) {
|
||||
abschnitt(dokument, 'Notizen zum Gesprächsleitfaden');
|
||||
for (const notiz of notizen) {
|
||||
platzPruefen(dokument, 40);
|
||||
dokument
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(9.5)
|
||||
.fillColor(FARBEN.leise)
|
||||
.text(fuerPdf(notiz.abschnitt), SEITE.rand, dokument.y, { width: INHALTSBREITE });
|
||||
dokument
|
||||
.font('Helvetica')
|
||||
.fontSize(10)
|
||||
.fillColor(FARBEN.text)
|
||||
.text(fuerPdf(notiz.notiz), SEITE.rand, dokument.y + 1, { width: INHALTSBREITE });
|
||||
dokument.x = SEITE.rand;
|
||||
dokument.y += 6;
|
||||
}
|
||||
}
|
||||
|
||||
if (inhalt.sonstiges.trim()) freitext(dokument, 'Sonstiges', inhalt.sonstiges);
|
||||
|
||||
// --- Bestätigungsvermerk ---
|
||||
if (angaben.bestaetigtAzubiAm && angaben.bestaetigtAusbilderAm) {
|
||||
platzPruefen(dokument, 40);
|
||||
dokument.moveDown(0.8);
|
||||
dokument
|
||||
.font('Helvetica-Oblique')
|
||||
.fontSize(8.5)
|
||||
.fillColor(FARBEN.gut)
|
||||
.text(
|
||||
fuerPdf(
|
||||
`In der Lern-Bar bestätigt: Auszubildender am ${angaben.bestaetigtAzubiAm}, ` +
|
||||
`Ausbilder am ${angaben.bestaetigtAusbilderAm}. Das Gespräch ist damit unveränderlich.`,
|
||||
),
|
||||
SEITE.rand,
|
||||
dokument.y,
|
||||
{ width: INHALTSBREITE },
|
||||
);
|
||||
dokument.x = SEITE.rand;
|
||||
}
|
||||
|
||||
unterschriften(dokument, ['Datum, Unterschrift Auszubildender', 'Datum, Unterschrift Ausbilder']);
|
||||
|
||||
fusszeilen(dokument, fuerPdf(`Feedbackgespräch ${angaben.azubi} - ${angaben.datum} - Lern-Bar`));
|
||||
|
||||
return alsPuffer(dokument);
|
||||
}
|
||||
|
||||
function skalentext(inhalt: Gespraechsinhalt): string {
|
||||
const erste = inhalt.skala[0];
|
||||
const letzte = inhalt.skala.at(-1);
|
||||
if (!erste || !letzte) return '';
|
||||
return `${erste.wert} = ${erste.text} bis ${letzte.wert} = ${letzte.text}`;
|
||||
}
|
||||
|
||||
/** Kriterien mit den beiden Einschätzungen nebeneinander. */
|
||||
function bewertungstabelle(dokument: Dokument, inhalt: Gespraechsinhalt): void {
|
||||
const spalteAusbilder = 58;
|
||||
const spalteAzubi = 58;
|
||||
const textBreite = INHALTSBREITE - spalteAusbilder - spalteAzubi - 12;
|
||||
|
||||
// Kopfzeile
|
||||
platzPruefen(dokument, 26);
|
||||
const kopfY = dokument.y;
|
||||
dokument.rect(SEITE.rand, kopfY, INHALTSBREITE, 16).fillColor(FARBEN.flaeche).fill();
|
||||
dokument
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(8.5)
|
||||
.fillColor(FARBEN.leise)
|
||||
.text('Kriterium', SEITE.rand + 6, kopfY + 4, { width: textBreite });
|
||||
dokument.text('Ausbilder', SEITE.breite - SEITE.rand - spalteAusbilder - spalteAzubi, kopfY + 4, {
|
||||
width: spalteAusbilder,
|
||||
align: 'center',
|
||||
});
|
||||
dokument.text('Azubi', SEITE.breite - SEITE.rand - spalteAzubi, kopfY + 4, {
|
||||
width: spalteAzubi,
|
||||
align: 'center',
|
||||
});
|
||||
dokument.x = SEITE.rand;
|
||||
dokument.y = kopfY + 20;
|
||||
|
||||
for (const kriterium of inhalt.kriterien) {
|
||||
const bewertung = inhalt.bewertungen.find((b) => b.kriteriumId === kriterium.id);
|
||||
|
||||
dokument.font('Helvetica').fontSize(10);
|
||||
const titelHoehe = dokument.heightOfString(fuerPdf(kriterium.titel), { width: textBreite });
|
||||
const bemerkung = bewertung?.bemerkung.trim() ?? '';
|
||||
const bemerkungHoehe = bemerkung
|
||||
? dokument.font('Helvetica-Oblique').fontSize(8.5).heightOfString(fuerPdf(bemerkung), { width: textBreite }) + 2
|
||||
: 0;
|
||||
|
||||
platzPruefen(dokument, titelHoehe + bemerkungHoehe + 10);
|
||||
const y = dokument.y;
|
||||
|
||||
// Wo beide Seiten weit auseinanderliegen, muss das auch auf dem Papier zu
|
||||
// sehen sein – zwei Zahlen nebeneinander sagen es sonst nur dem, der
|
||||
// nachrechnet.
|
||||
const auseinander = liegtWeitAuseinander(bewertung);
|
||||
if (auseinander) {
|
||||
dokument
|
||||
.rect(SEITE.rand, y - 3, 2.5, titelHoehe + bemerkungHoehe + 4)
|
||||
.fillColor(FARBEN.bernstein)
|
||||
.fill();
|
||||
}
|
||||
|
||||
dokument
|
||||
.font('Helvetica')
|
||||
.fontSize(10)
|
||||
.fillColor(FARBEN.text)
|
||||
.text(fuerPdf(kriterium.titel), SEITE.rand + 6, y, { width: textBreite });
|
||||
|
||||
if (bemerkung) {
|
||||
dokument
|
||||
.font('Helvetica-Oblique')
|
||||
.fontSize(8.5)
|
||||
.fillColor(FARBEN.leise)
|
||||
.text(fuerPdf(bemerkung), SEITE.rand + 6, dokument.y + 1, { width: textBreite });
|
||||
}
|
||||
|
||||
const unten = dokument.y;
|
||||
|
||||
dokument
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(11)
|
||||
.fillColor(auseinander ? FARBEN.warnung : FARBEN.text)
|
||||
.text(stufe(bewertung?.ausbilder ?? null), SEITE.breite - SEITE.rand - spalteAusbilder - spalteAzubi, y, {
|
||||
width: spalteAusbilder,
|
||||
align: 'center',
|
||||
});
|
||||
dokument
|
||||
.font(auseinander ? 'Helvetica-Bold' : 'Helvetica')
|
||||
.fontSize(11)
|
||||
.fillColor(auseinander ? FARBEN.warnung : FARBEN.leise)
|
||||
.text(stufe(bewertung?.azubi ?? null), SEITE.breite - SEITE.rand - spalteAzubi, y, {
|
||||
width: spalteAzubi,
|
||||
align: 'center',
|
||||
});
|
||||
|
||||
dokument.x = SEITE.rand;
|
||||
dokument.y = Math.max(unten, y + titelHoehe) + 5;
|
||||
|
||||
dokument
|
||||
.moveTo(SEITE.rand, dokument.y - 2)
|
||||
.lineTo(SEITE.breite - SEITE.rand, dokument.y - 2)
|
||||
.lineWidth(0.4)
|
||||
.strokeColor(FARBEN.linie)
|
||||
.stroke();
|
||||
}
|
||||
|
||||
dokument.fillColor(FARBEN.text);
|
||||
}
|
||||
|
||||
function stufe(wert: number | null): string {
|
||||
return wert === null ? '–' : String(wert);
|
||||
}
|
||||
|
||||
function freitext(dokument: Dokument, titel: string, text: string): void {
|
||||
abschnitt(dokument, titel);
|
||||
platzPruefen(dokument, 30);
|
||||
|
||||
dokument
|
||||
.font(text.trim() ? 'Helvetica' : 'Helvetica-Oblique')
|
||||
.fontSize(10)
|
||||
.fillColor(text.trim() ? FARBEN.text : FARBEN.leise)
|
||||
.text(fuerPdf(text.trim() || 'Keine Eintragung.'), SEITE.rand, dokument.y, { width: INHALTSBREITE });
|
||||
|
||||
dokument.x = SEITE.rand;
|
||||
dokument.y += 4;
|
||||
}
|
||||
@@ -19,6 +19,9 @@ export const FARBEN = {
|
||||
linie: '#C8D8DE',
|
||||
flaeche: '#EDF3F5',
|
||||
gut: '#1D6F4A',
|
||||
// Dunkleres Bernstein: das helle Akzentgelb ist auf Weiß nicht lesbar genug,
|
||||
// sobald es Text trägt statt nur einen Balken.
|
||||
warnung: '#8A5214',
|
||||
} as const;
|
||||
|
||||
export const SEITE = {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { datenbank, schema } from '../db';
|
||||
import { heute, jetzt } from '../zeit';
|
||||
import { leererInhalt, leseInhalt, type Gespraechsinhalt } from '../feedback/inhalt';
|
||||
import type { Feedbackvorlage } from '../inhalte/schema';
|
||||
import type { Feedbackgespraech } from '../db/schema';
|
||||
|
||||
/** Feedbackgespräche. Der Inhalt liegt als JSON, samt kopierter Vorlage. */
|
||||
|
||||
export function gespraecheVon(ausbildungId: string): Feedbackgespraech[] {
|
||||
return datenbank()
|
||||
.select()
|
||||
.from(schema.feedbackgespraech)
|
||||
.where(eq(schema.feedbackgespraech.ausbildungId, ausbildungId))
|
||||
.orderBy(desc(schema.feedbackgespraech.datum))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function gespraechNachId(id: string): Feedbackgespraech | null {
|
||||
return (
|
||||
datenbank().select().from(schema.feedbackgespraech).where(eq(schema.feedbackgespraech.id, id)).get() ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function anlegen(ausbildungId: string, angelegtVon: string, vorlage: Feedbackvorlage, datum = heute()): Feedbackgespraech {
|
||||
const datensatz: Feedbackgespraech = {
|
||||
id: crypto.randomUUID(),
|
||||
ausbildungId,
|
||||
datum,
|
||||
inhalt: JSON.stringify(leererInhalt(vorlage)),
|
||||
bestaetigtAzubi: 0,
|
||||
bestaetigtAzubiAm: null,
|
||||
bestaetigtAusbilder: 0,
|
||||
bestaetigtAusbilderAm: null,
|
||||
angelegtVon,
|
||||
angelegtAm: jetzt(),
|
||||
geaendertAm: jetzt(),
|
||||
};
|
||||
|
||||
datenbank().insert(schema.feedbackgespraech).values(datensatz).run();
|
||||
return datensatz;
|
||||
}
|
||||
|
||||
export function inhaltVon(gespraech: Feedbackgespraech, ersatz: Feedbackvorlage): Gespraechsinhalt {
|
||||
return leseInhalt(gespraech.inhalt, ersatz);
|
||||
}
|
||||
|
||||
export function inhaltSpeichern(id: string, inhalt: Gespraechsinhalt, datum?: string): void {
|
||||
datenbank()
|
||||
.update(schema.feedbackgespraech)
|
||||
.set({ inhalt: JSON.stringify(inhalt), ...(datum ? { datum } : {}), geaendertAm: jetzt() })
|
||||
.where(eq(schema.feedbackgespraech.id, id))
|
||||
.run();
|
||||
}
|
||||
|
||||
export function bestaetigen(id: string, rolle: 'azubi' | 'ausbilder'): void {
|
||||
const feld =
|
||||
rolle === 'azubi'
|
||||
? { bestaetigtAzubi: 1, bestaetigtAzubiAm: jetzt() }
|
||||
: { bestaetigtAusbilder: 1, bestaetigtAusbilderAm: jetzt() };
|
||||
|
||||
datenbank()
|
||||
.update(schema.feedbackgespraech)
|
||||
.set({ ...feld, geaendertAm: jetzt() })
|
||||
.where(eq(schema.feedbackgespraech.id, id))
|
||||
.run();
|
||||
}
|
||||
|
||||
/** Bestätigung zurücknehmen – geht nur, solange die andere Seite noch nicht bestätigt hat. */
|
||||
export function bestaetigungZuruecknehmen(id: string, rolle: 'azubi' | 'ausbilder'): void {
|
||||
const feld =
|
||||
rolle === 'azubi'
|
||||
? { bestaetigtAzubi: 0, bestaetigtAzubiAm: null }
|
||||
: { bestaetigtAusbilder: 0, bestaetigtAusbilderAm: null };
|
||||
|
||||
datenbank()
|
||||
.update(schema.feedbackgespraech)
|
||||
.set({ ...feld, geaendertAm: jetzt() })
|
||||
.where(eq(schema.feedbackgespraech.id, id))
|
||||
.run();
|
||||
}
|
||||
|
||||
export function loeschen(id: string): void {
|
||||
datenbank().delete(schema.feedbackgespraech).where(eq(schema.feedbackgespraech.id, id)).run();
|
||||
}
|
||||