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>
This commit is contained in:
Florian Wilde
2026-09-11 14:08:49 +02:00
co-authored by Claude Opus 5
parent d226a6bae8
commit be2a674c7a
53 changed files with 3138 additions and 4 deletions
+46
View File
@@ -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
+47
View File
@@ -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 |
+58
View File
@@ -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.
+4 -3
View File
@@ -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
```
+9 -1
View File
@@ -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
+75
View File
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 278 KiB

+1
View File
@@ -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(
+237
View File
@@ -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);
+28
View File
@@ -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();
+409
View File
@@ -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',
},
});
}
+180
View File
@@ -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');
}
+152
View File
@@ -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('.', ',');
}
+4
View File
@@ -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' },
];
+171
View File
@@ -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; }
}
+161
View File
@@ -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);
});
});
+173
View File
@@ -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 };
});
}
+141
View File
@@ -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>;
+25
View File
@@ -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);
+24
View File
@@ -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,
};
+171
View File
@@ -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);
});
});
+172
View File
@@ -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;
}
+18
View File
@@ -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 }));
+20
View File
@@ -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(),
};
+24
View File
@@ -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>;
+228
View File
@@ -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;
}
+3
View File
@@ -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 = {
+86
View File
@@ -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();
}